diff --git a/.github/banner-dark.png b/.github/banner-dark.png new file mode 100644 index 000000000..2d52207a2 Binary files /dev/null and b/.github/banner-dark.png differ diff --git a/.github/banner.png b/.github/banner.png new file mode 100644 index 000000000..39e0983a3 Binary files /dev/null and b/.github/banner.png differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..2371beafb --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +vite.config.ts.* +node_modules +dist +out +dist-ssr +*.local + +# Editor directories and files +.idea +.DS_Store +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.turbo +.env +.env.* +!.env.example +!.env.local + +# Other +.netlify +.react-email +.wrangler +backend/files +frontend/stats.html +frontend/src/generated + +*.zip \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..937f80021 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-import-method=clone-or-copy \ No newline at end of file diff --git a/LICENSE b/LICENSE index 754652bf3..35a080675 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 cellajs +Copyright (c) 2024 CellaJS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..ce1c6348d --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +
+ + + + +
+ + + + +

+

Cella

+

+ Single stack TypeScript template to build local-first SaaS. +
+
+ Website + · + prerelease version + · + MIT license +

+
+
+

+ +
+ +#### Prerelease + +> ❗ Please be aware this is a prerelease. It does not meet production requirements yet and large breaking changes still occur regularly. Want to contribute? Let's connect! ✉️ + + +#### Contents +- [Installation](#installation) +- [Architecture](/info/ARCHITECTURE.md) +- [Roadmap](/info/ROADMAP.md) +- [Deployment](/info/DEPLOYMENT.md) + +## Installation + +#### Prerequisites +- **Node:** Check node with `node -v`. Install Node 20.x or 22.x. (ie. [Volta](https://docs.volta.sh/guide/)). +- **Docker:** Install [Orbstack](https://orbstack.dev/) or [Docker](https://docs.docker.com/get-docker/) + +### Step 1 + +```bash +git clone git@github.com:cellajs/cella.git && cd cella +``` + +Create a `.env` in `/env` folder. Recommended defaults are in `.env.example`. Then install: + +```bash +pnpm install +``` + +Make sure docker runs in the background with a postgres db in it. + +```bash +pnpm run docker +``` + +### Step 2 +Page-related resources are handled by a conventional API. Content-related resources are use a local-first strategy with [ElectricSQL](https://github.com/electric-sql/electric). +Therefore, `generate` and `migrate` commands will execute both for normal schemas and for electric schemas in `/backend`. + +```bash +pnpm run generate +pnpm run migrate +``` + +Generate local-first sync layer in `/frontend` with [electric](https://github.com/electric-sql/electric) + +```bash +pnpm run electrify +``` + +Check it out at [localhost:3000](http://localhost:3000) after + +```bash +pnpm run dev +``` + +### Step 3 + +The user [seed](/backend/seed/README.md) is required to add an ADMIN user + +```bash +pnpm run seed +``` + +Manage your local db on [local.drizzle.studio](http:local.drizzle.studio) + +```bash +pnpm run studio +``` + +### API documentation +Autogenerated [OpenAPI docs](https://api.cellajs.com/docs). Docs refresh on changes at [localhost:4000/docs](http://localhost:4000/docs) + + +### More info +- Please [install](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) [Biome](https://biomejs.dev/) for code style. Fix with `pnpm run check:fix` and type check with `pnpm run check:types` +- EADDRINUSE errors? Try `sudo lsof -i :1080 -i :3000 -i :4000` and then `kill -9 *PID*` with a space-separated list of `PID` +- pnpm cache issues? Try `pnpm store prune` +- turbo cache issues? Try adding `--force` to the command +- docker cache issues? Try `docker builder prune --force` + +
+
+ +💙💛 Big thank you too [drizzle-orm](https://github.com/drizzle-team/drizzle-orm), [hono](https://github.com/honojs/hono), [tanstack-router](https://github.com/tanstack/router), [electric-sql](https://github.com/electric-sql/electric) & [shadcn](https://github.com/shadcn-ui/ui). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..27e936eeb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +If you have a security issue to report, please contact us at [security@cellajs.com](mailto:security@cellajs.com). \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..387b4257c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ + +# Sentry Config File +.sentryclirc diff --git a/backend/drizzle-electric/0000_funny_skaar.sql b/backend/drizzle-electric/0000_funny_skaar.sql new file mode 100644 index 000000000..2545ecaf0 --- /dev/null +++ b/backend/drizzle-electric/0000_funny_skaar.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS "labels" ( + "id" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "color" varchar, + "organization_id" varchar NOT NULL, + "project_id" varchar NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "tasks" ( + "id" varchar PRIMARY KEY NOT NULL, + "slug" varchar NOT NULL, + "markdown" varchar, + "summary" varchar NOT NULL, + "type" varchar NOT NULL, + "impact" integer, + "sort_order" double precision NOT NULL, + "status" integer NOT NULL, + "parent_id" varchar, + "labels" jsonb NOT NULL, + "assigned_to" jsonb NOT NULL, + "organization_id" varchar NOT NULL, + "project_id" varchar NOT NULL, + "created_at" timestamp NOT NULL, + "created_by" varchar NOT NULL, + "assigned_by" varchar, + "assigned_at" timestamp, + "modified_at" timestamp, + "modified_by" varchar, + CONSTRAINT "tasks_parent_id_tasks_id_fk" FOREIGN KEY ("parent_id") REFERENCES "tasks"("id") ON DELETE no action ON UPDATE no action +); diff --git a/backend/drizzle-electric/0001_aromatic_giant_girl.sql b/backend/drizzle-electric/0001_aromatic_giant_girl.sql new file mode 100644 index 000000000..415f2e8bc --- /dev/null +++ b/backend/drizzle-electric/0001_aromatic_giant_girl.sql @@ -0,0 +1,3 @@ +ALTER TABLE "tasks" ENABLE ELECTRIC; +--> statement-breakpoint +ALTER TABLE "labels" ENABLE ELECTRIC; diff --git a/backend/drizzle-electric/meta/0000_snapshot.json b/backend/drizzle-electric/meta/0000_snapshot.json new file mode 100644 index 000000000..94dff8669 --- /dev/null +++ b/backend/drizzle-electric/meta/0000_snapshot.json @@ -0,0 +1,193 @@ +{ + "id": "a6738fe7-9688-4c9d-8bc6-72630db7d8d3", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "markdown": { + "name": "markdown", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "impact": { + "name": "impact", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "assigned_to": { + "name": "assigned_to", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_parent_id_tasks_id_fk": { + "name": "tasks_parent_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle-electric/meta/0001_snapshot.json b/backend/drizzle-electric/meta/0001_snapshot.json new file mode 100644 index 000000000..29fc49a38 --- /dev/null +++ b/backend/drizzle-electric/meta/0001_snapshot.json @@ -0,0 +1,193 @@ +{ + "id": "f6229716-1bdd-459d-8b21-cfae96c9c98e", + "prevId": "a6738fe7-9688-4c9d-8bc6-72630db7d8d3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "markdown": { + "name": "markdown", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "impact": { + "name": "impact", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "assigned_to": { + "name": "assigned_to", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_parent_id_tasks_id_fk": { + "name": "tasks_parent_id_tasks_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "parent_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle-electric/meta/_journal.json b/backend/drizzle-electric/meta/_journal.json new file mode 100644 index 000000000..43d692be5 --- /dev/null +++ b/backend/drizzle-electric/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1719305766875, + "tag": "0000_funny_skaar", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1719305786171, + "tag": "0001_aromatic_giant_girl", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/drizzle.config.electric.ts b/backend/drizzle.config.electric.ts new file mode 100644 index 000000000..f8b812ef6 --- /dev/null +++ b/backend/drizzle.config.electric.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; +import { env } from 'env'; + +export default defineConfig({ + schema: './src/db/schema-electric/*', + out: './drizzle-electric', + dialect: 'postgresql', + dbCredentials: { + url: env.ELECTRIC_SYNC_URL ?? '', + }, +}); diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 000000000..933e89ff7 --- /dev/null +++ b/backend/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; +import { env } from 'env'; + +export default defineConfig({ + schema: './src/db/schema/*', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: env.DATABASE_URL ?? '', + }, +}); diff --git a/backend/drizzle/0000_petite_meggan.sql b/backend/drizzle/0000_petite_meggan.sql new file mode 100644 index 000000000..4f724eb31 --- /dev/null +++ b/backend/drizzle/0000_petite_meggan.sql @@ -0,0 +1,289 @@ +CREATE TABLE IF NOT EXISTS "memberships" ( + "id" varchar PRIMARY KEY NOT NULL, + "type" varchar DEFAULT 'ORGANIZATION' NOT NULL, + "organization_id" varchar, + "workspace_id" varchar, + "project_id" varchar, + "user_id" varchar, + "role" varchar DEFAULT 'MEMBER' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" varchar, + "modified_at" timestamp, + "modified_by" varchar, + "inactive" boolean DEFAULT false, + "muted" boolean DEFAULT false +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "oauth_accounts" ( + "provider_id" varchar NOT NULL, + "provider_user_id" varchar NOT NULL, + "user_id" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_accounts_provider_id_provider_user_id_pk" PRIMARY KEY("provider_id","provider_user_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" varchar PRIMARY KEY NOT NULL, + "entity" varchar DEFAULT 'ORGANIZATION' NOT NULL, + "name" varchar NOT NULL, + "short_name" varchar, + "slug" varchar NOT NULL, + "country" varchar, + "timezone" varchar, + "default_language" varchar DEFAULT 'en' NOT NULL, + "languages" json DEFAULT '["en"]'::json NOT NULL, + "notification_email" varchar, + "email_domains" json, + "brand_color" varchar, + "thumbnail_url" varchar, + "logo_url" varchar, + "banner_url" varchar, + "website_url" varchar, + "welcome_text" varchar, + "is_production" boolean DEFAULT false NOT NULL, + "auth_strategies" json, + "chat_support" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" varchar, + "modified_at" timestamp, + "modified_by" varchar, + CONSTRAINT "organizations_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "projects_to_workspaces" ( + "project_id" varchar NOT NULL, + "workspace_id" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "projects_to_workspaces_project_id_workspace_id_pk" PRIMARY KEY("project_id","workspace_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "projects" ( + "id" varchar PRIMARY KEY NOT NULL, + "entity" varchar DEFAULT 'PROJECT' NOT NULL, + "slug" varchar NOT NULL, + "name" varchar NOT NULL, + "color" varchar NOT NULL, + "organization_id" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" varchar, + "modified_at" timestamp, + "modified_by" varchar +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "requests" ( + "id" varchar PRIMARY KEY NOT NULL, + "user_id" varchar, + "organization_id" varchar, + "message" varchar, + "email" varchar NOT NULL, + "type" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" varchar PRIMARY KEY NOT NULL, + "user_id" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "tokens" ( + "id" varchar PRIMARY KEY NOT NULL, + "type" varchar NOT NULL, + "email" varchar, + "role" varchar, + "user_id" varchar, + "organization_id" varchar, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" varchar PRIMARY KEY NOT NULL, + "entity" varchar DEFAULT 'USER' NOT NULL, + "hashed_password" varchar, + "slug" varchar NOT NULL, + "name" varchar NOT NULL, + "first_name" varchar, + "last_name" varchar, + "email" varchar NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "bio" varchar, + "language" varchar DEFAULT 'en' NOT NULL, + "banner_url" varchar, + "thumbnail_url" varchar, + "newsletter" boolean DEFAULT false NOT NULL, + "last_seen_at" timestamp, + "last_visit_at" timestamp, + "last_sign_in_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "modified_at" timestamp, + "modified_by" varchar, + "role" varchar DEFAULT 'USER' NOT NULL, + CONSTRAINT "users_slug_unique" UNIQUE("slug"), + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "workspaces" ( + "id" varchar PRIMARY KEY NOT NULL, + "entity" varchar DEFAULT 'WORKSPACE' NOT NULL, + "name" varchar NOT NULL, + "slug" varchar NOT NULL, + "organization_id" varchar NOT NULL, + "thumbnail_url" varchar, + "banner_url" varchar, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" varchar, + "modified_at" timestamp, + "modified_by" varchar, + CONSTRAINT "workspaces_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organizations" ADD CONSTRAINT "organizations_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organizations" ADD CONSTRAINT "organizations_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects_to_workspaces" ADD CONSTRAINT "projects_to_workspaces_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects_to_workspaces" ADD CONSTRAINT "projects_to_workspaces_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects" ADD CONSTRAINT "projects_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "requests" ADD CONSTRAINT "requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "requests" ADD CONSTRAINT "requests_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tokens" ADD CONSTRAINT "tokens_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "users" ADD CONSTRAINT "users_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "organizations_name_index" ON "organizations" USING btree (name DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "organizations_created_at_index" ON "organizations" USING btree (created_at DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_id_index" ON "projects_to_workspaces" USING btree (workspace_id DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "requests_emails" ON "requests" USING btree (email DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "requests_created_at" ON "requests" USING btree (created_at DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_name_index" ON "users" USING btree (name DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_email_index" ON "users" USING btree (email DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_created_at_index" ON "users" USING btree (created_at DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_name_index" ON "workspaces" USING btree (name DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_created_at_index" ON "workspaces" USING btree (created_at DESC NULLS LAST); \ No newline at end of file diff --git a/backend/drizzle/0001_absurd_black_panther.sql b/backend/drizzle/0001_absurd_black_panther.sql new file mode 100644 index 000000000..d990c0601 --- /dev/null +++ b/backend/drizzle/0001_absurd_black_panther.sql @@ -0,0 +1,7 @@ +ALTER TABLE "organizations" RENAME COLUMN "brand_color" TO "color";--> statement-breakpoint +ALTER TABLE "requests" DROP CONSTRAINT "requests_user_id_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "requests" DROP CONSTRAINT "requests_organization_id_organizations_id_fk"; +--> statement-breakpoint +ALTER TABLE "requests" DROP COLUMN IF EXISTS "user_id";--> statement-breakpoint +ALTER TABLE "requests" DROP COLUMN IF EXISTS "organization_id"; \ No newline at end of file diff --git a/backend/drizzle/0002_broad_randall.sql b/backend/drizzle/0002_broad_randall.sql new file mode 100644 index 000000000..edecc0be1 --- /dev/null +++ b/backend/drizzle/0002_broad_randall.sql @@ -0,0 +1,3 @@ +ALTER TABLE "memberships" ALTER COLUMN "type" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "memberships" ALTER COLUMN "inactive" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ALTER COLUMN "muted" SET NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0003_reflective_korvac.sql b/backend/drizzle/0003_reflective_korvac.sql new file mode 100644 index 000000000..1d8fc9c72 --- /dev/null +++ b/backend/drizzle/0003_reflective_korvac.sql @@ -0,0 +1,2 @@ +ALTER TABLE "projects" ADD COLUMN "thumbnail_url" varchar;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "banner_url" varchar; \ No newline at end of file diff --git a/backend/drizzle/0004_eager_katie_power.sql b/backend/drizzle/0004_eager_katie_power.sql new file mode 100644 index 000000000..7bfd4cca4 --- /dev/null +++ b/backend/drizzle/0004_eager_katie_power.sql @@ -0,0 +1 @@ +ALTER TABLE "memberships" ADD COLUMN "sort_order" double precision NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0005_even_morlocks.sql b/backend/drizzle/0005_even_morlocks.sql new file mode 100644 index 000000000..1381c43cb --- /dev/null +++ b/backend/drizzle/0005_even_morlocks.sql @@ -0,0 +1,4 @@ +ALTER TABLE "organizations" ALTER COLUMN "email_domains" SET DEFAULT '[]'::json;--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "email_domains" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "auth_strategies" SET DEFAULT '[]'::json;--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "auth_strategies" SET NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..493796970 --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1282 @@ +{ + "id": "648bd85d-e400-4466-8610-425b002f663a", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_user_id_users_id_fk": { + "name": "requests_user_id_users_id_fk", + "tableFrom": "requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "requests_organization_id_organizations_id_fk": { + "name": "requests_organization_id_organizations_id_fk", + "tableFrom": "requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..737b70ea6 --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1243 @@ +{ + "id": "c3c19f09-67d1-4dfd-a8be-7bb75d625231", + "prevId": "648bd85d-e400-4466-8610-425b002f663a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..9722e73e3 --- /dev/null +++ b/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1242 @@ +{ + "id": "a717807b-1c52-417b-b784-d4d71d23691a", + "prevId": "c3c19f09-67d1-4dfd-a8be-7bb75d625231", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..a92f2662d --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1254 @@ +{ + "id": "217c70f4-8021-4da0-80e3-a075108efca8", + "prevId": "a717807b-1c52-417b-b784-d4d71d23691a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0004_snapshot.json b/backend/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..9ffc284e4 --- /dev/null +++ b/backend/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1260 @@ +{ + "id": "c928819e-bb26-495f-abb0-c5c9f72f7c42", + "prevId": "217c70f4-8021-4da0-80e3-a075108efca8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..03dde66fa --- /dev/null +++ b/backend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1262 @@ +{ + "id": "8b8ea8ac-7455-426e-a389-c4b86f9d7e2d", + "prevId": "c928819e-bb26-495f-abb0-c5c9f72f7c42", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "inactive": { + "name": "inactive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "muted": { + "name": "muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_project_id_projects_id_fk": { + "name": "memberships_project_id_projects_id_fk", + "tableFrom": "memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_created_by_users_id_fk": { + "name": "memberships_created_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "memberships_modified_by_users_id_fk": { + "name": "memberships_modified_by_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'ORGANIZATION'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "default_language": { + "name": "default_language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"en\"]'::json" + }, + "notification_email": { + "name": "notification_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email_domains": { + "name": "email_domains", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'::json" + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "welcome_text": { + "name": "welcome_text", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_production": { + "name": "is_production", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_strategies": { + "name": "auth_strategies", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'::json" + }, + "chat_support": { + "name": "chat_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_name_index": { + "name": "organizations_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_index": { + "name": "organizations_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_created_by_users_id_fk": { + "name": "organizations_created_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organizations_modified_by_users_id_fk": { + "name": "organizations_modified_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.projects_to_workspaces": { + "name": "projects_to_workspaces", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_index": { + "name": "workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_to_workspaces_project_id_projects_id_fk": { + "name": "projects_to_workspaces_project_id_projects_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_to_workspaces_workspace_id_workspaces_id_fk": { + "name": "projects_to_workspaces_workspace_id_workspaces_id_fk", + "tableFrom": "projects_to_workspaces", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_to_workspaces_project_id_workspace_id_pk": { + "name": "projects_to_workspaces_project_id_workspace_id_pk", + "columns": [ + "project_id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'PROJECT'" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_modified_by_users_id_fk": { + "name": "projects_modified_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_emails": { + "name": "requests_emails", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_created_at": { + "name": "requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tokens_user_id_users_id_fk": { + "name": "tokens_user_id_users_id_fk", + "tableFrom": "tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tokens_organization_id_organizations_id_fk": { + "name": "tokens_organization_id_organizations_id_fk", + "tableFrom": "tokens", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "hashed_password": { + "name": "hashed_password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_visit_at": { + "name": "last_visit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_index": { + "name": "users_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_modified_by_users_id_fk": { + "name": "users_modified_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_slug_unique": { + "name": "users_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE'" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_name_index": { + "name": "workspace_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_created_at_index": { + "name": "workspace_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_users_id_fk": { + "name": "workspaces_created_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_modified_by_users_id_fk": { + "name": "workspaces_modified_by_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 000000000..12e4ca7e6 --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,48 @@ +{ + "version": "1", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1717592551334, + "tag": "0000_petite_meggan", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1717689916822, + "tag": "0001_absurd_black_panther", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1717786035152, + "tag": "0002_broad_randall", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1718199865429, + "tag": "0003_reflective_korvac", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1718521780408, + "tag": "0004_eager_katie_power", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1719304887623, + "tag": "0005_even_morlocks", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..374dc4c29 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,91 @@ +{ + "name": "backend", + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=20.14.0" + }, + "scripts": { + "start": "tsx dist/index.cjs", + "build": "NODE_ENV=production tsup", + "build:sourcemaps": "NODE_ENV=production tsup && pnpm run sentry:sourcemaps", + "build:dev": "tsup", + "check:types": "tsc", + "dev": "tsup --watch --onSuccess \"tsx dist/index.cjs\"", + "seed:pivotal": "tsx seed/pivotal.ts", + "seed:user": "tsx seed/user/index.ts", + "seed:organizations": "tsx seed/organizations/index.ts", + "seed:data": "tsx seed/data/index.ts", + "generate:pg": "drizzle-kit generate --config drizzle.config.ts", + "generate:electric": "drizzle-kit generate --config drizzle.config.electric.ts", + "generate:electric:custom": "drizzle-kit generate --config drizzle.config.electric.ts --custom", + "push": "drizzle-kit push", + "migrate:pg": "tsx src/db/migrate.ts", + "migrate:electric": "tsx src/db/migrate.electric.ts", + "migrate:script": "tsx dist/src/db/migrate.cjs", + "seeds:script": "tsx dist/seed/user.cjs && tsx dist/seed/data.cjs", + "studio": "drizzle-kit studio --config drizzle.config.ts", + "studio:electric": "drizzle-kit studio --config drizzle.config.electric.ts", + "proxy": "electric-sql proxy-tunnel --service https://electric-sync.cellajs.com --local-port 65432", + "sentry:sourcemaps": "sentry-cli sourcemaps inject --org cella --project backend ./dist && sentry-cli sourcemaps upload --org cella --project backend ./dist" + }, + "dependencies": { + "@cellajs/imado": "^0.1.2", + "@cellajs/permission-manager": "^0.1.0", + "@hono/node-server": "^1.11.2", + "@hono/sentry": "^1.1.0", + "@hono/zod-openapi": "^0.14.1", + "@logtail/node": "^0.4.21", + "@lucia-auth/adapter-drizzle": "^1.0.7", + "@lucia-auth/adapter-postgresql": "^3.1.2", + "@novu/node": "^0.24.2", + "@paddle/paddle-node-sdk": "^1.3.0", + "@react-email/render": "^0.0.1", + "@scalar/hono-api-reference": "^0.5.61", + "@sentry/cli": "^2.31.2", + "@types/jsonwebtoken": "^9.0.6", + "arctic": "^1.9.0", + "config": "workspace:*", + "drizzle-orm": "^0.31.2", + "drizzle-zod": "^0.5.1", + "electric-sql": "^0.12.0", + "email": "workspace:*", + "enforce-unique": "^1.3.0", + "env": "workspace:*", + "hono": "^4.4.5", + "i18next": "^23.11.3", + "isbot": "^5.1.8", + "jsonwebtoken": "^9.0.2", + "locales": "workspace:*", + "lucia": "^3.2.0", + "nanoid": "^5.0.7", + "node-cron": "^3.0.3", + "oslo": "^1.2.0", + "pg": "^8.11.5", + "postgres": "^3.4.4", + "rate-limiter-flexible": "^5.0.3", + "react-i18next": "^14.1.2", + "slugify": "^1.6.6", + "zod": "^3.23.8" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.24.6", + "@faker-js/faker": "^8.4.1", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.11.6", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "drizzle-kit": "^0.22.1", + "hanji": "^0.0.5", + "jszip": "^3.10.1", + "papaparse": "^5.4.1", + "tsup": "^8.1.0", + "tsx": "^4.11.2" + }, + "babel": { + "presets": ["@babel/preset-typescript"] + }, + "exports": { + "./*": "./src/*.ts" + } +} diff --git a/backend/seed/README.md b/backend/seed/README.md new file mode 100644 index 000000000..81daff63e --- /dev/null +++ b/backend/seed/README.md @@ -0,0 +1,25 @@ + +# Seed Scripts Overview + +1. User seed (required) +This script seeds the database with an admin user. + +Admin User: Creates an admin user with a verified email(admin-test@cellajs.com) and password 12345678. + +2. Organizations seed +This script seeds the database with organizations, users and memberships. + +- Organizations: Generates 10 unique organizations. +- Users: Creates 100 users for each organization. +- Memberships: Associates users with their respective organizations. +- Admin User: Adds an admin user to every even organization. + +3. Data seed +This is an app-specific seed. Currently, this script seeds additional data: workspaces, projects, tasks and labels. + +4. Pivotal seed +App-specific seed to migrate data from pivotal to cella. + +``` +pnpm seed:pivotal -- --file '/Users/flip/pivotal-cella.zip' --project wppoiso9icl77yyb4n519 +``` \ No newline at end of file diff --git a/backend/seed/data/index.ts b/backend/seed/data/index.ts new file mode 100644 index 000000000..da0166f86 --- /dev/null +++ b/backend/seed/data/index.ts @@ -0,0 +1,45 @@ +import { renderWithTask } from 'hanji'; +import { Progress, type State } from '../progress'; +import { dataSeed } from './seed'; + +const DataState: State = { + workspaces: { + count: 0, + name: 'workspaces', + status: 'inserting', + }, + projects: { + count: 0, + name: 'projects', + status: 'inserting', + }, + tasks: { + count: 0, + name: 'tasks ⚡', + status: 'inserting', + }, + labels: { + count: 0, + name: 'labels ⚡', + status: 'inserting', + }, + memberships: { + count: 0, + name: 'memberships', + status: 'inserting', + }, +}; + +const progress = new Progress(DataState); + +renderWithTask( + progress, + dataSeed((stage, count, status) => { + progress.update(stage, count, status); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => process.exit(0)), +); diff --git a/backend/seed/data/seed.ts b/backend/seed/data/seed.ts new file mode 100644 index 000000000..9ffe5b2d6 --- /dev/null +++ b/backend/seed/data/seed.ts @@ -0,0 +1,212 @@ +import { faker } from '@faker-js/faker'; + +import { db } from '../../src/db/db'; +import { nanoid } from '../../src/lib/nanoid'; + +import { UniqueEnforcer } from 'enforce-unique'; +import { labelsTable, type InsertLabelModel } from '../../src/db/schema-electric/labels'; +import { tasksTable, type InsertTaskModel } from '../../src/db/schema-electric/tasks'; +import { membershipsTable, type InsertMembershipModel } from '../../src/db/schema/memberships'; +import { organizationsTable } from '../../src/db/schema/organizations'; +import { projectsTable, type InsertProjectModel } from '../../src/db/schema/projects'; +import { projectsToWorkspacesTable } from '../../src/db/schema/projects-to-workspaces'; +import { workspacesTable, type InsertWorkspaceModel } from '../../src/db/schema/workspaces'; +import type { Status } from '../progress'; +import { adminUser } from '../user/seed'; + +export const dataSeed = async (progressCallback?: (stage: string, count: number, status: Status) => void) => { + const organizations = await db.select().from(organizationsTable); + const memberships = await db.select().from(membershipsTable); + + const organizationsUniqueEnforcer = new UniqueEnforcer(); + + let workspacesCount = 0; + let projectsCount = 0; + let tasksCount = 0; + let labelsCount = 0; + let membershipsCount = 0; + + for (const organization of organizations) { + const orgMemberships = memberships.filter((m) => m.organizationId === organization.id); + const users = orgMemberships.map((el) => { + return { id: el.userId as string }; + }); + + const insertWorkspaces: InsertWorkspaceModel[] = Array.from({ length: 5 }).map(() => { + const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); + + return { + id: nanoid(), + organizationId: organization.id, + name: faker.company.name(), + slug: faker.helpers.slugify(name).toLowerCase(), + bannerUrl: faker.image.url(), + thumbnailUrl: faker.image.url(), + createdAt: faker.date.past(), + createdBy: orgMemberships[Math.floor(Math.random() * orgMemberships.length)].userId, + modifiedAt: faker.date.past(), + modifiedBy: orgMemberships[Math.floor(Math.random() * orgMemberships.length)].userId, + }; + }); + + const workspaces = await db.insert(workspacesTable).values(insertWorkspaces).returning().onConflictDoNothing(); + + const groupSize = 10; + let groupIndex = 0; + for (const workspace of workspaces) { + workspacesCount++; + if (progressCallback) progressCallback('workspaces', workspacesCount, 'inserting'); + + // slice users to 10 members for each workspace + const membersGroup = users.slice(groupIndex * groupSize, (groupIndex + 1) * groupSize); + groupIndex++; + + const workspaceMemberships: InsertMembershipModel[] = membersGroup.map((user) => { + return { + id: nanoid(), + type: 'WORKSPACE', + userId: user.id, + organizationId: organization.id, + workspaceId: workspace.id, + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: workspacesCount + 1, + }; + }); + + membershipsCount += workspaceMemberships.length; + + if (progressCallback) progressCallback('memberships', membershipsCount, 'inserting'); + // add admin user to every even workspace + if (workspacesCount % 2 === 0) { + workspaceMemberships.push({ + id: nanoid(), + type: 'WORKSPACE', + userId: adminUser.id, + organizationId: organization.id, + workspaceId: workspace.id, + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: workspacesCount + 1, + }); + } + await db.insert(membershipsTable).values(workspaceMemberships).onConflictDoNothing(); + + const insertProjects: InsertProjectModel[] = Array.from({ length: 3 }).map(() => { + const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); + + return { + id: nanoid(), + organizationId: organization.id, + name, + color: faker.internet.color(), + slug: faker.helpers.slugify(name).toLowerCase(), + createdAt: faker.date.past(), + createdBy: membersGroup[Math.floor(Math.random() * membersGroup.length)].id, + modifiedAt: faker.date.past(), + modifiedBy: membersGroup[Math.floor(Math.random() * membersGroup.length)].id, + }; + }); + + const projects = await db.insert(projectsTable).values(insertProjects).returning().onConflictDoNothing(); + + for (const project of projects) { + projectsCount++; + if (progressCallback) progressCallback('projects', projectsCount, 'inserting'); + //assign project to workspace + await db.insert(projectsToWorkspacesTable).values({ + projectId: project.id, + workspaceId: workspace.id, + }); + + const projectMemberships: InsertMembershipModel[] = membersGroup.map((user) => { + return { + id: nanoid(), + userId: user.id, + type: 'PROJECT', + organizationId: organization.id, + projectId: project.id, + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: projectsCount + 1, + }; + }); + + // add admin user to every even project in every even workspace + if (workspacesCount % 2 === 0 && projectsCount % 2 === 0) { + projectMemberships.push({ + id: nanoid(), + userId: adminUser.id, + type: 'PROJECT', + organizationId: organization.id, + projectId: project.id, + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: projectsCount + 1, + }); + } + + membershipsCount += projectMemberships.length; + if (progressCallback) progressCallback('memberships', membershipsCount, 'inserting'); + + await db.insert(membershipsTable).values(projectMemberships).onConflictDoNothing(); + + const insertTasks: InsertTaskModel[] = Array.from({ length: 50 }).map((_, index) => { + const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); + + return { + id: nanoid(), + organizationId: organization.id, + projectId: project.id, + summary: name, + slug: faker.helpers.slugify(name).toLowerCase(), + // TODO: fix this + order: index, + // random integer between 0 and 6 + status: Math.floor(Math.random() * 7), + type: faker.helpers.arrayElement(['bug', 'feature', 'chore']), + // random integer between 0 and 3 + impact: Math.floor(Math.random() * 4), + markdown: faker.lorem.paragraphs(), + createdAt: faker.date.past(), + createdBy: membersGroup[Math.floor(Math.random() * membersGroup.length)].id, + modifiedAt: faker.date.past(), + modifiedBy: membersGroup[Math.floor(Math.random() * membersGroup.length)].id, + assignedAt: faker.date.past(), + assignedBy: membersGroup[Math.floor(Math.random() * membersGroup.length)].id, + }; + }); + + tasksCount += insertTasks.length; + if (progressCallback) progressCallback('tasks', tasksCount, 'inserting'); + + await db.insert(tasksTable).values(insertTasks).onConflictDoNothing(); + + const insertLabels: InsertLabelModel[] = Array.from({ length: 5 }).map(() => { + const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); + + return { + id: nanoid(), + organizationId: organization.id, + projectId: project.id, + name, + color: faker.internet.color(), + }; + }); + + labelsCount += insertLabels.length; + if (progressCallback) progressCallback('labels', labelsCount, 'inserting'); + + await db.insert(labelsTable).values(insertLabels).onConflictDoNothing(); + } + } + } + + if (progressCallback) { + progressCallback('memberships', membershipsCount, 'done'); + progressCallback('labels', labelsCount, 'done'); + progressCallback('tasks', tasksCount, 'done'); + progressCallback('projects', projectsCount, 'done'); + progressCallback('workspaces', workspacesCount, 'done'); + } +}; diff --git a/backend/seed/organizations/index.ts b/backend/seed/organizations/index.ts new file mode 100644 index 000000000..88c7d83eb --- /dev/null +++ b/backend/seed/organizations/index.ts @@ -0,0 +1,35 @@ +import { renderWithTask } from 'hanji'; +import { Progress, type State } from '../progress'; +import { organizationsSeed } from './seed'; + +const OrganizationsState: State = { + organizations: { + count: 0, + name: 'organizations', + status: 'inserting', + }, + users: { + count: 0, + name: 'users', + status: 'inserting', + }, + memberships: { + count: 0, + name: 'memberships', + status: 'inserting', + }, +}; + +const progress = new Progress(OrganizationsState); + +renderWithTask( + progress, + organizationsSeed((stage, count, status) => { + progress.update(stage, count, status); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => process.exit(0)), +); diff --git a/backend/seed/organizations/seed.ts b/backend/seed/organizations/seed.ts new file mode 100644 index 000000000..39dbe8f3e --- /dev/null +++ b/backend/seed/organizations/seed.ts @@ -0,0 +1,133 @@ +import { faker } from '@faker-js/faker'; +import { UniqueEnforcer } from 'enforce-unique'; +import { Argon2id } from 'oslo/password'; + +import { config } from 'config'; +import { db } from '../../src/db/db'; +import { nanoid } from '../../src/lib/nanoid'; + +import { membershipsTable, type InsertMembershipModel } from '../../src/db/schema/memberships'; +import { organizationsTable, type InsertOrganizationModel } from '../../src/db/schema/organizations'; +import { usersTable, type InsertUserModel } from '../../src/db/schema/users'; +import type { Status } from '../progress'; +import { adminUser } from '../user/seed'; + +// Seed organizations with data +export const organizationsSeed = async (progressCallback?: (stage: string, count: number, status: Status) => void) => { + const organizationsInTable = await db.select().from(organizationsTable).limit(1); + + if (organizationsInTable.length > 0) { + console.info('Organizations table is not empty, skipping seed'); + return; + } + + const organizationsUniqueEnforcer = new UniqueEnforcer(); + + const organizations: (InsertOrganizationModel & { + id: string; + })[] = Array.from({ + length: 10, + }).map(() => { + const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); + + return { + id: nanoid(), + name, + slug: faker.helpers.slugify(name).toLowerCase(), + bannerUrl: faker.image.url(), + color: faker.internet.color(), + chatSupport: faker.datatype.boolean(), + country: faker.location.country(), + createdAt: faker.date.past(), + logoUrl: faker.image.url(), + thumbnailUrl: faker.image.url(), + }; + }); + + await db.insert(organizationsTable).values(organizations).onConflictDoNothing(); + + const hashedPassword = await new Argon2id().hash('12345678'); + + const usersSlugUniqueEnforcer = new UniqueEnforcer(); + const usersEmailUniqueEnforcer = new UniqueEnforcer(); + + let usersCount = 0; + let organizationsCount = 0; + let membershipsCount = 0; + + // Create 100 users for each organization + for (const organization of organizations) { + organizationsCount++; + if (progressCallback) progressCallback('organizations', organizationsCount, 'inserting'); + + const insertUsers: InsertUserModel[] = Array.from({ length: 100 }).map(() => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const firstAndLastName = { firstName, lastName }; + + const name = faker.person.fullName(firstAndLastName); + const email = usersEmailUniqueEnforcer.enforce(() => faker.internet.email(firstAndLastName).toLocaleLowerCase()); + const slug = usersSlugUniqueEnforcer.enforce(() => + faker.internet + .userName(firstAndLastName) + .toLowerCase() + .replace(/[^a-z0-9]/g, '-'), + ); + + return { + id: nanoid(), + firstName, + lastName, + thumbnailUrl: faker.image.avatar(), + language: config.defaultLanguage, + name, + email, + hashedPassword, + slug, + avatarUrl: faker.image.avatar(), + createdAt: faker.date.past(), + }; + }); + + usersCount += insertUsers.length; + if (progressCallback) progressCallback('users', usersCount, 'inserting'); + + const users = await db.insert(usersTable).values(insertUsers).returning().onConflictDoNothing(); + + // Create 100 memberships for each organization + const memberships: InsertMembershipModel[] = users.map((user) => { + return { + id: nanoid(), + userId: user.id, + organizationId: organization.id, + type: 'ORGANIZATION', + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: organizationsCount + 1, + }; + }); + + // add Admin user to every even organization + if (organizationsCount % 2 === 0) { + memberships.push({ + id: nanoid(), + userId: adminUser.id, + organizationId: organization.id, + type: 'ORGANIZATION', + role: faker.helpers.arrayElement(['ADMIN', 'MEMBER']), + createdAt: faker.date.past(), + order: organizationsCount + 1, + }); + } + membershipsCount += memberships.length; + if (progressCallback) progressCallback('memberships', membershipsCount, 'inserting'); + + await db.insert(membershipsTable).values(memberships).onConflictDoNothing(); + } + + if (progressCallback) { + progressCallback('memberships', membershipsCount, 'done'); + progressCallback('users', usersCount, 'done'); + progressCallback('organizations', organizationsCount, 'done'); + } +}; diff --git a/backend/seed/pivotal.ts b/backend/seed/pivotal.ts new file mode 100644 index 000000000..64e0ee82c --- /dev/null +++ b/backend/seed/pivotal.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import { Command } from 'commander'; +import { eq } from 'drizzle-orm'; +import JSZip from 'jszip'; +import papaparse from 'papaparse'; +import { db } from '../src/db/db.electric'; +import { tasksTable } from '../src/db/schema-electric/tasks'; +import { projectsTable } from '../src/db/schema/projects'; + +const program = new Command().option('--file ', 'Zip file to upload').option('--project ', 'Project to upload tasks to').parse(); + +const options = program.opts(); +const zipFile = options.file; +const projectId = options.project; + +if (!zipFile) { + console.error('Please provide a zip file'); + process.exit(1); +} + +if (!projectId) { + console.error('Please provide a project id'); + process.exit(1); +} + +const [project] = await db.select().from(projectsTable).where(eq(projectsTable.id, projectId)); + +if (!project) { + console.error('Project not found'); + process.exit(1); +} + +const zip = new JSZip(); +const data = fs.readFileSync(zipFile); +zip.loadAsync(data).then(async (zip) => { + const promises: Promise[] = []; + zip.forEach((relativePath, zipEntry) => { + if (!relativePath.endsWith('.csv') || relativePath.includes('project_history')) { + return; + } + const promise = new Promise((resolve) => { + zipEntry.async('nodebuffer').then((content) => { + const csv = content.toString(); + const result = papaparse.parse(csv, { header: true }); + resolve(result.data); + }); + }); + promises.push(promise); + }); + const [tasks] = await Promise.all< + [ + { + Id: string; + Title: string; + Type: string; + Priority: string; + Estimate: string; + Description: string; + 'Current State': string; + // Jan 7, 2024 + 'Created at': string; + }[], + ] + // biome-ignore lint/suspicious/noExplicitAny: + >(promises as any); + await db + .insert(tasksTable) + .values( + tasks.map((task, index) => { + return { + slug: task.Id, + summary: task.Title || 'No title', + type: (task.Type || 'chore') as 'feature' | 'bug' | 'chore', + createdBy: 'pivotal', + organizationId: project.organizationId, + projectId: project.id, + impact: ['0', '1', '2', '3'].includes(task.Estimate) ? +task.Estimate : 0, + markdown: `${task.Title}\n${task.Description}`, + status: + task['Current State'] === 'accepted' + ? 6 + : task['Current State'] === 'reviewed' + ? 5 + : task['Current State'] === 'delivered' + ? 4 + : task['Current State'] === 'finished' + ? 3 + : task['Current State'] === 'started' + ? 2 + : task['Current State'] === 'unstarted' + ? 1 + : 0, + order: index, + createdAt: new Date(), + }; + }), + ) + .onConflictDoNothing(); + + console.info('Done'); + process.exit(0); +}); diff --git a/backend/seed/progress.ts b/backend/seed/progress.ts new file mode 100644 index 000000000..359402522 --- /dev/null +++ b/backend/seed/progress.ts @@ -0,0 +1,86 @@ +import chalk from 'chalk'; + +import { TaskView } from 'hanji'; + +class Spinner { + private offset = 0; + private readonly iterator: () => void; + + constructor(private readonly frames: string[]) { + this.iterator = () => { + this.offset += 1; + this.offset %= frames.length - 1; + }; + } + + public tick = () => { + this.iterator(); + }; + + public value = () => { + return this.frames[this.offset]; + }; +} + +type ValueOf = T[keyof T]; +export type Status = 'inserting' | 'done'; + +export type State = { + [key: string]: { + count: number; + name: string; + status: Status; + }; +}; + +export class Progress extends TaskView { + private state: State; + private readonly spinner: Spinner = new Spinner('⣷⣯⣟⡿⢿⣻⣽⣾'.split('')); + private timeout: NodeJS.Timeout | undefined; + + constructor(state: State) { + super(); + this.timeout = setInterval(() => { + this.spinner.tick(); + this.requestLayout(); + }, 128); + this.state = state; + this.on('detach', () => clearInterval(this.timeout)); + } + + public update(stage: string, count: number, status: Status) { + this.state[stage].count = count; + this.state[stage].status = status; + this.requestLayout(); + } + + private formatCount = (count: number) => { + const width: number = Math.max.apply( + null, + Object.values(this.state).map((it) => it.count.toFixed(0).length), + ); + + return count.toFixed(0).padEnd(width, ' '); + }; + + private statusText = (spinner: string, stage: ValueOf) => { + const { name, count } = stage; + const isDone = stage.status === 'done'; + + const prefix = isDone ? `[${chalk.green('✓')}]` : `[${spinner}]`; + + const formattedCount = this.formatCount(count); + const suffix = isDone ? `${formattedCount} ${name} inserted` : `${formattedCount} ${name} inserting`; + + return `${prefix} ${suffix}\n`; + }; + + render(): string { + let info = ''; + const spin = this.spinner.value(); + for (const stage of Object.values(this.state)) { + info += this.statusText(spin, stage); + } + return info; + } +} diff --git a/backend/seed/user/index.ts b/backend/seed/user/index.ts new file mode 100644 index 000000000..0c90ef94c --- /dev/null +++ b/backend/seed/user/index.ts @@ -0,0 +1,8 @@ +import { userSeed } from './seed'; + +userSeed() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => process.exit(0)); diff --git a/backend/seed/user/seed.ts b/backend/seed/user/seed.ts new file mode 100644 index 000000000..b4e139815 --- /dev/null +++ b/backend/seed/user/seed.ts @@ -0,0 +1,37 @@ +import { config } from 'config'; +import { db } from '../../src/db/db'; +import { usersTable } from '../../src/db/schema/users'; + +import { Argon2id } from 'oslo/password'; + +export const adminUser = { + password: '12345678', + email: 'admin-test@cellajs.com', + id: 'admin12345678', +}; + +// Seed an admin user to access app first time +export const userSeed = async () => { + const usersInTable = await db.select().from(usersTable).limit(1); + + if (usersInTable.length > 0) { + console.info('Users table is not empty, skipping seed'); + return; + } + + await db + .insert(usersTable) + .values({ + id: adminUser.id, + email: adminUser.email, + emailVerified: true, + name: 'Admin User', + language: config.defaultLanguage, + slug: 'admin-user', + role: 'ADMIN', + hashedPassword: await new Argon2id().hash(adminUser.password), + }) + .onConflictDoNothing(); + + console.info(`Created admin user with verified email ${adminUser.email} and password ${adminUser.password}.`); +}; diff --git a/backend/src/cron/reset-db.ts b/backend/src/cron/reset-db.ts new file mode 100644 index 000000000..3eb463803 --- /dev/null +++ b/backend/src/cron/reset-db.ts @@ -0,0 +1,20 @@ +import { dataSeed } from '../../seed/data/seed'; +import { organizationsSeed } from '../../seed/organizations/seed'; +import { userSeed } from '../../seed/user/seed'; + +import { db } from '../db/db'; +import { organizationsTable } from '../db/schema/organizations'; +import { usersTable } from '../db/schema/users'; + +export const resetDb = async () => { + await db.delete(organizationsTable); + await db.delete(usersTable); + + console.info('Deleted all organizations and users.'); + + await userSeed(); + await organizationsSeed(); + await dataSeed(); + + console.info('Database reset complete.'); +}; diff --git a/backend/src/db/db.electric.ts b/backend/src/db/db.electric.ts new file mode 100644 index 000000000..f672fa651 --- /dev/null +++ b/backend/src/db/db.electric.ts @@ -0,0 +1,13 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { env } from 'env'; +import pg from 'pg'; + +import { config } from 'config'; + +const queryClient = new pg.Pool({ + connectionString: env.ELECTRIC_SYNC_URL, +}); + +export const db = drizzle(queryClient, { + logger: config.debug, +}); diff --git a/backend/src/db/db.ts b/backend/src/db/db.ts new file mode 100644 index 000000000..9bbcbec5a --- /dev/null +++ b/backend/src/db/db.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { env } from 'env'; +import pg from 'pg'; + +import { config } from 'config'; +import { sql } from 'drizzle-orm'; + +export const queryClient = new pg.Pool({ + connectionString: env.DATABASE_URL, +}); + +export const db = drizzle(queryClient, { + logger: config.debug, +}); + +export const coalesce = (column: T, value: number) => sql`COALESCE(${column}, ${value})`.mapWith(Number); diff --git a/backend/src/db/lucia.ts b/backend/src/db/lucia.ts new file mode 100644 index 000000000..a8f5800d8 --- /dev/null +++ b/backend/src/db/lucia.ts @@ -0,0 +1,57 @@ +import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle'; +import { GitHub, Google, MicrosoftEntraId } from 'arctic'; +import { config } from 'config'; +import { Lucia, type SessionCookieOptions, TimeSpan } from 'lucia'; + +import { env } from 'env'; +import authRoutesConfig from '../modules/auth/routes'; +import { db } from './db'; +import { sessionsTable } from './schema/sessions'; +import { type UserModel, usersTable } from './schema/users'; + +export const githubAuth = new GitHub(env.GITHUB_CLIENT_ID || '', env.GITHUB_CLIENT_SECRET || '', { + redirectURI: config.backendAuthUrl + authRoutesConfig.githubSignInCallback.path, +}); + +export const googleAuth = new Google( + env.GOOGLE_CLIENT_ID || '', + env.GOOGLE_CLIENT_SECRET || '', + config.backendAuthUrl + authRoutesConfig.googleSignInCallback.path, +); + +export const microsoftAuth = new MicrosoftEntraId( + env.MICROSOFT_TENANT_ID || '', + env.MICROSOFT_CLIENT_ID || '', + env.MICROSOFT_CLIENT_SECRET || '', + config.backendAuthUrl + authRoutesConfig.microsoftSignInCallback.path, +); + +// Create Lucia adapter instance +const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable); +const isProduction = config.mode === 'production'; + +const sessionCookieOptions: SessionCookieOptions = { + name: `${config.slug}-session-v1`, + expires: true, + attributes: { + secure: isProduction, + sameSite: isProduction ? 'lax' : 'lax', + }, +}; + +export const auth = new Lucia(adapter, { + sessionExpiresIn: new TimeSpan(4, 'w'), // Set session expiration to 4 weeks + sessionCookie: sessionCookieOptions, + getUserAttributes({ hashedPassword, ...databaseUserAttributes }) { + return databaseUserAttributes; + }, +}); + +export type Auth = typeof auth; + +declare module 'lucia' { + interface Register { + Lucia: typeof auth; + DatabaseUserAttributes: UserModel; + } +} diff --git a/backend/src/db/migrate.electric.ts b/backend/src/db/migrate.electric.ts new file mode 100644 index 000000000..9ee21964e --- /dev/null +++ b/backend/src/db/migrate.electric.ts @@ -0,0 +1,19 @@ +import { migrate } from 'drizzle-orm/node-postgres/migrator'; + +import { db } from './db.electric'; + +async function main() { + console.info('Running electric migrations'); + + await migrate(db, { migrationsFolder: 'drizzle-electric', migrationsSchema: 'drizzle-electric' }); + + console.info('Migrated successfully'); + + process.exit(0); +} + +main().catch((e) => { + console.error('Migration failed'); + console.error(e); + process.exit(1); +}); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 000000000..56d9f6d47 --- /dev/null +++ b/backend/src/db/migrate.ts @@ -0,0 +1,19 @@ +import { migrate } from 'drizzle-orm/node-postgres/migrator'; + +import { db } from './db'; + +async function main() { + console.info('Running migrations'); + + await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: 'drizzle-backend' }); + + console.info('Migrated successfully'); + + process.exit(0); +} + +main().catch((e) => { + console.error('Migration failed'); + console.error(e); + process.exit(1); +}); diff --git a/backend/src/db/schema-electric/labels.ts b/backend/src/db/schema-electric/labels.ts new file mode 100644 index 000000000..f5edc0def --- /dev/null +++ b/backend/src/db/schema-electric/labels.ts @@ -0,0 +1,13 @@ +import { pgTable, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; + +export const labelsTable = pgTable('labels', { + id: varchar('id').primaryKey().$defaultFn(nanoid), + name: varchar('name').notNull(), + color: varchar('color'), + organizationId: varchar('organization_id').notNull(), + projectId: varchar('project_id').notNull(), +}); + +export type LabelModel = typeof labelsTable.$inferSelect; +export type InsertLabelModel = typeof labelsTable.$inferInsert; diff --git a/backend/src/db/schema-electric/tasks.ts b/backend/src/db/schema-electric/tasks.ts new file mode 100644 index 000000000..3c8a19631 --- /dev/null +++ b/backend/src/db/schema-electric/tasks.ts @@ -0,0 +1,34 @@ +import { type AnyPgColumn, doublePrecision, integer, jsonb, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; + +export const tasksTable = pgTable('tasks', { + id: varchar('id').primaryKey().$defaultFn(nanoid), + slug: varchar('slug').notNull(), + markdown: varchar('markdown'), + summary: varchar('summary').notNull(), + type: varchar('type', { + enum: ['bug', 'feature', 'chore'], + }).notNull(), + impact: integer('impact'), + // order is a reserved keyword in Postgres, so we need to use a different name + order: doublePrecision('sort_order').notNull(), + status: integer('status').notNull(), + parentId: varchar('parent_id').references((): AnyPgColumn => tasksTable.id), + labels: jsonb('labels').$type().notNull().$defaultFn(() => []), + // labels: jsonb('labels').$type().notNull().default([]), + assignedTo: jsonb('assigned_to').$type().notNull().$defaultFn(() => []), + // assignedTo: jsonb('assigned_to').$type().notNull().default([]), + organizationId: varchar('organization_id').notNull(), + projectId: varchar('project_id').notNull(), + createdAt: timestamp('created_at') + // .defaultNow() + .notNull(), + createdBy: varchar('created_by').notNull(), + assignedBy: varchar('assigned_by'), + assignedAt: timestamp('assigned_at'), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by'), +}); + +export type TaskModel = typeof tasksTable.$inferSelect; +export type InsertTaskModel = typeof tasksTable.$inferInsert; diff --git a/backend/src/db/schema/memberships.ts b/backend/src/db/schema/memberships.ts new file mode 100644 index 000000000..996fd87d0 --- /dev/null +++ b/backend/src/db/schema/memberships.ts @@ -0,0 +1,53 @@ +import { config } from 'config'; +import { relations } from 'drizzle-orm'; +import { boolean, doublePrecision, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; +import { organizationsTable } from './organizations'; +import { projectsTable } from './projects'; +import { usersTable } from './users'; +import { workspacesTable } from './workspaces'; + +const roleEnum = config.rolesByType.entityRoles; + +export const membershipsTable = pgTable('memberships', { + id: varchar('id').primaryKey().$defaultFn(nanoid), + type: varchar('type', { enum: config.contextEntityTypes }).notNull(), + organizationId: varchar('organization_id').references(() => organizationsTable.id, { onDelete: 'cascade' }), + workspaceId: varchar('workspace_id').references(() => workspacesTable.id, { onDelete: 'cascade' }), + projectId: varchar('project_id').references(() => projectsTable.id, { onDelete: 'cascade' }), + userId: varchar('user_id').references(() => usersTable.id, { onDelete: 'cascade' }), + role: varchar('role', { enum: roleEnum }).notNull().default('MEMBER'), + createdAt: timestamp('created_at').defaultNow().notNull(), + createdBy: varchar('created_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + inactive: boolean('inactive').default(false).notNull(), + muted: boolean('muted').default(false).notNull(), + order: doublePrecision('sort_order').notNull(), +}); + +export const membershipsTableRelations = relations(membershipsTable, ({ one }) => ({ + user: one(usersTable, { + fields: [membershipsTable.userId], + references: [usersTable.id], + }), + organization: one(organizationsTable, { + fields: [membershipsTable.organizationId], + references: [organizationsTable.id], + }), + workspace: one(workspacesTable, { + fields: [membershipsTable.workspaceId], + references: [workspacesTable.id], + }), + project: one(projectsTable, { + fields: [membershipsTable.projectId], + references: [projectsTable.id], + }), +})); + +export type MembershipModel = typeof membershipsTable.$inferSelect; +export type InsertMembershipModel = typeof membershipsTable.$inferInsert; diff --git a/backend/src/db/schema/oauth-accounts.ts b/backend/src/db/schema/oauth-accounts.ts new file mode 100644 index 000000000..cb7e2bc48 --- /dev/null +++ b/backend/src/db/schema/oauth-accounts.ts @@ -0,0 +1,22 @@ +import { config } from 'config'; +import { pgTable, primaryKey, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { usersTable } from './users'; + +export const oauthAccountsTable = pgTable( + 'oauth_accounts', + { + providerId: varchar('provider_id', { enum: config.oauthProviderOptions }).notNull(), + providerUserId: varchar('provider_user_id').notNull(), + userId: varchar('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => { + return { + pk: primaryKey({ + columns: [table.providerId, table.providerUserId], + }), + }; + }, +); diff --git a/backend/src/db/schema/organizations.ts b/backend/src/db/schema/organizations.ts new file mode 100644 index 000000000..fca54a7e8 --- /dev/null +++ b/backend/src/db/schema/organizations.ts @@ -0,0 +1,59 @@ +import { config } from 'config'; +import { relations } from 'drizzle-orm'; +import { boolean, index, json, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; +import { membershipsTable } from './memberships'; +import { usersTable } from './users'; + +export const organizationsTable = pgTable( + 'organizations', + { + id: varchar('id').primaryKey().$defaultFn(nanoid), + entity: varchar('entity', { enum: ['ORGANIZATION'] }) + .notNull() + .default('ORGANIZATION'), + name: varchar('name').notNull(), + shortName: varchar('short_name'), + slug: varchar('slug').unique().notNull(), + country: varchar('country'), + timezone: varchar('timezone'), + defaultLanguage: varchar('default_language', { + enum: ['en', 'nl'], + }) + .notNull() + .default(config.defaultLanguage), + languages: json('languages').$type().notNull().default([config.defaultLanguage]), + notificationEmail: varchar('notification_email'), + emailDomains: json('email_domains').$type().notNull().default([]), + color: varchar('color'), + thumbnailUrl: varchar('thumbnail_url'), + bannerUrl: varchar('banner_url'), + logoUrl: varchar('logo_url'), + websiteUrl: varchar('website_url'), + welcomeText: varchar('welcome_text'), + isProduction: boolean('is_production').notNull().default(false), + authStrategies: json('auth_strategies').$type().notNull().default([]), + chatSupport: boolean('chat_support').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + createdBy: varchar('created_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + }, + (table) => { + return { + nameIndex: index('organizations_name_index').on(table.name.desc()), + createdAtIndex: index('organizations_created_at_index').on(table.createdAt.desc()), + }; + }, +); + +export const organizationsTableRelations = relations(organizationsTable, ({ many }) => ({ + users: many(membershipsTable), +})); + +export type OrganizationModel = typeof organizationsTable.$inferSelect; +export type InsertOrganizationModel = typeof organizationsTable.$inferInsert; diff --git a/backend/src/db/schema/projects-to-workspaces.ts b/backend/src/db/schema/projects-to-workspaces.ts new file mode 100644 index 000000000..169a0269b --- /dev/null +++ b/backend/src/db/schema/projects-to-workspaces.ts @@ -0,0 +1,43 @@ +import { relations } from 'drizzle-orm'; +import { index, pgTable, primaryKey, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { projectsTable } from './projects'; +import { workspacesTable } from './workspaces'; + +export const projectsToWorkspacesTable = pgTable( + 'projects_to_workspaces', + { + projectId: varchar('project_id') + .notNull() + .references(() => projectsTable.id, { + onDelete: 'cascade', + }), + workspaceId: varchar('workspace_id') + .notNull() + .references(() => workspacesTable.id, { + onDelete: 'cascade', + }), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => { + return { + pk: primaryKey({ + columns: [table.projectId, table.workspaceId], + }), + workspaceIndex: index('workspace_id_index').on(table.workspaceId.desc()), + }; + }, +); + +export const projectsToWorkspacesRelations = relations(projectsToWorkspacesTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [projectsToWorkspacesTable.projectId], + references: [projectsTable.id], + }), + workspace: one(workspacesTable, { + fields: [projectsToWorkspacesTable.workspaceId], + references: [workspacesTable.id], + }), +})); + +export type ProjectToWorkspaceModel = typeof projectsToWorkspacesTable.$inferSelect; +export type InsertProjectToWorkspaceModel = typeof projectsToWorkspacesTable.$inferInsert; diff --git a/backend/src/db/schema/projects.ts b/backend/src/db/schema/projects.ts new file mode 100644 index 000000000..ed923c702 --- /dev/null +++ b/backend/src/db/schema/projects.ts @@ -0,0 +1,40 @@ +import { relations } from 'drizzle-orm'; +import { pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; +import { membershipsTable } from './memberships'; +import { organizationsTable } from './organizations'; +import { projectsToWorkspacesTable } from './projects-to-workspaces'; +import { usersTable } from './users'; + +export const projectsTable = pgTable('projects', { + id: varchar('id').primaryKey().$defaultFn(nanoid), + entity: varchar('entity', { enum: ['PROJECT'] }) + .notNull() + .default('PROJECT'), + slug: varchar('slug').notNull(), + name: varchar('name').notNull(), + color: varchar('color').notNull(), + thumbnailUrl: varchar('thumbnail_url'), + bannerUrl: varchar('banner_url'), + organizationId: varchar('organization_id') + .notNull() + .references(() => organizationsTable.id, { + onDelete: 'cascade', + }), + createdAt: timestamp('created_at').defaultNow().notNull(), + createdBy: varchar('created_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { + onDelete: 'set null', + }), +}); + +export const projectsTableRelations = relations(projectsTable, ({ many }) => ({ + users: many(membershipsTable), + workspaces: many(projectsToWorkspacesTable), +})); + +export type ProjectModel = typeof projectsTable.$inferSelect; +export type InsertProjectModel = typeof projectsTable.$inferInsert; diff --git a/backend/src/db/schema/requests.ts b/backend/src/db/schema/requests.ts new file mode 100644 index 000000000..bef7fa9e9 --- /dev/null +++ b/backend/src/db/schema/requests.ts @@ -0,0 +1,25 @@ +import { index, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; + +const requestTypeEnum = ['WAITLIST_REQUEST', 'NEWSLETTER_REQUEST', 'CONTACT_REQUEST'] as const; +export type RequestType = (typeof requestTypeEnum)[number]; + +export const requestsTable = pgTable( + 'requests', + { + id: varchar('id').primaryKey().$defaultFn(nanoid), + message: varchar('message'), + email: varchar('email').notNull(), + type: varchar('type', { enum: requestTypeEnum }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => { + return { + emailIndex: index('requests_emails').on(table.email.desc()), + createdAtIndex: index('requests_created_at').on(table.createdAt.desc()), + }; + }, +); + +export type RequestsModel = typeof requestsTable.$inferSelect; +export type InsertRequestModel = typeof requestsTable.$inferInsert; diff --git a/backend/src/db/schema/sessions.ts b/backend/src/db/schema/sessions.ts new file mode 100644 index 000000000..47af488ba --- /dev/null +++ b/backend/src/db/schema/sessions.ts @@ -0,0 +1,11 @@ +import { pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { usersTable } from './users'; + +export const sessionsTable = pgTable('sessions', { + id: varchar('id').primaryKey(), + userId: varchar('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(), +}); diff --git a/backend/src/db/schema/tokens.ts b/backend/src/db/schema/tokens.ts new file mode 100644 index 000000000..f15abe9cd --- /dev/null +++ b/backend/src/db/schema/tokens.ts @@ -0,0 +1,21 @@ +import { config } from 'config'; +import { pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { organizationsTable } from './organizations'; +import { usersTable } from './users'; + +const tokenTypeEnum = ['EMAIL_VERIFICATION', 'PASSWORD_RESET', 'SYSTEM_INVITATION', 'ORGANIZATION_INVITATION'] as const; +const roleEnum = config.rolesByType.allRoles; + +export const tokensTable = pgTable('tokens', { + id: varchar('id').primaryKey(), + type: varchar('type', { enum: tokenTypeEnum }).notNull(), + email: varchar('email'), + role: varchar('role', { enum: roleEnum }), + userId: varchar('user_id').references(() => usersTable.id, { onDelete: 'cascade' }), + organizationId: varchar('organization_id').references(() => organizationsTable.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(), +}); + +export type TokenModel = typeof tokensTable.$inferSelect; +export type InsertTokenModel = typeof tokensTable.$inferInsert; diff --git a/backend/src/db/schema/users.ts b/backend/src/db/schema/users.ts new file mode 100644 index 000000000..b6afb54a9 --- /dev/null +++ b/backend/src/db/schema/users.ts @@ -0,0 +1,57 @@ +import { config } from 'config'; +import { relations } from 'drizzle-orm'; +import { boolean, foreignKey, index, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { membershipsTable } from './memberships'; + +const roleEnum = config.rolesByType.systemRoles; + +export const usersTable = pgTable( + 'users', + { + id: varchar('id').primaryKey(), + entity: varchar('entity', { enum: ['USER'] }) + .notNull() + .default('USER'), + hashedPassword: varchar('hashed_password'), + slug: varchar('slug').unique().notNull(), + name: varchar('name').notNull(), + firstName: varchar('first_name'), + lastName: varchar('last_name'), + email: varchar('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull().default(false), + bio: varchar('bio'), + language: varchar('language', { + enum: ['en', 'nl'], + }) + .notNull() + .default(config.defaultLanguage), + bannerUrl: varchar('banner_url'), + thumbnailUrl: varchar('thumbnail_url'), + newsletter: boolean('newsletter').notNull().default(false), + lastSeenAt: timestamp('last_seen_at'), // last time any GET request has been made + lastVisitAt: timestamp('last_visit_at'), // last time GET me + lastSignInAt: timestamp('last_sign_in_at'), // last time user went through authentication flow + createdAt: timestamp('created_at').defaultNow().notNull(), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by'), + role: varchar('role', { enum: roleEnum }).notNull().default('USER'), + }, + (table) => { + return { + nameIndex: index('users_name_index').on(table.name.desc()), + emailIndex: index('users_email_index').on(table.email.desc()), + createdAtIndex: index('users_created_at_index').on(table.createdAt.desc()), + modifiedByReference: foreignKey({ + columns: [table.modifiedBy], + foreignColumns: [table.id], + }), + }; + }, +); + +export const usersTableRelations = relations(usersTable, ({ many }) => ({ + organizations: many(membershipsTable), +})); + +export type UserModel = typeof usersTable.$inferSelect; +export type InsertUserModel = typeof usersTable.$inferInsert; diff --git a/backend/src/db/schema/workspaces.ts b/backend/src/db/schema/workspaces.ts new file mode 100644 index 000000000..67dd4f41d --- /dev/null +++ b/backend/src/db/schema/workspaces.ts @@ -0,0 +1,48 @@ +import { relations } from 'drizzle-orm'; +import { index, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { nanoid } from '../../lib/nanoid'; +import { membershipsTable } from './memberships'; +import { organizationsTable } from './organizations'; +import { projectsToWorkspacesTable } from './projects-to-workspaces'; +import { usersTable } from './users'; + +export const workspacesTable = pgTable( + 'workspaces', + { + id: varchar('id').primaryKey().$defaultFn(nanoid), + entity: varchar('entity', { enum: ['WORKSPACE'] }) + .notNull() + .default('WORKSPACE'), + name: varchar('name').notNull(), + slug: varchar('slug').unique().notNull(), + organizationId: varchar('organization_id') + .notNull() + .references(() => organizationsTable.id, { + onDelete: 'cascade', + }), + thumbnailUrl: varchar('thumbnail_url'), + bannerUrl: varchar('banner_url'), + createdAt: timestamp('created_at').defaultNow().notNull(), + createdBy: varchar('created_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + }, + (table) => { + return { + nameIndex: index('workspace_name_index').on(table.name.desc()), + createdAtIndex: index('workspace_created_at_index').on(table.createdAt.desc()), + }; + }, +); + +export const workspaceTableRelations = relations(workspacesTable, ({ many }) => ({ + users: many(membershipsTable), + projects: many(projectsToWorkspacesTable), +})); + +export type WorkspaceModel = typeof workspacesTable.$inferSelect; +export type InsertWorkspaceModel = typeof workspacesTable.$inferInsert; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 000000000..228d4e736 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,41 @@ +import { serve } from '@hono/node-server'; +import cron from 'node-cron'; + +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { env } from 'env'; +import { resetDb } from './cron/reset-db'; +import { db } from './db/db'; +// import { db as dbElectric } from './db/db.electric'; +import ascii from './lib/ascii'; +import app from './server'; + +// Set i18n instance before starting server +import './lib/i18n'; + +const main = async () => { + // Reset db every Sunday at midnight + cron.schedule('0 0 * * 0', resetDb, { scheduled: true, timezone: 'UTC' }).start(); + + // Migrate db + await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: 'drizzle-backend' }); + // await migrate(dbElectric, { migrationsFolder: 'drizzle-electric', migrationsSchema: 'drizzle-electric' }); + + // Start server + serve( + { + fetch: app.fetch, + hostname: '0.0.0.0', + port: Number(env.PORT ?? '4000'), + }, + (info) => { + console.info(`Listening on http://${info.address}:${info.port}`); + ascii(); + }, + ); +}; + +main().catch((e) => { + console.error('Failed to start server'); + console.error(e); + process.exit(1); +}); diff --git a/backend/src/lib/all-entities.ts b/backend/src/lib/all-entities.ts new file mode 100644 index 000000000..9616f4205 --- /dev/null +++ b/backend/src/lib/all-entities.ts @@ -0,0 +1,84 @@ +// import { type AnyColumn, type SQL, type SQLWrapper, and, count, eq, ilike } from 'drizzle-orm'; +// import { membershipsTable } from '../db/schema/memberships'; +// import { db } from '../db/db'; +// import type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core'; +// import { getOrderColumn } from './order-column'; +// import { counts } from './counts'; +// import type { Entity } from '../types/common'; +// import { projectsTable } from '../db/schema/projects'; +// import { organizationsTable } from '../db/schema/organizations'; + +// export const getAllEntities = async ( +// entity: Exclude, +// { +// q, +// sort, +// order, +// offset, +// limit, +// }: { +// q: string; +// sort?: string; +// order?: 'asc' | 'desc'; +// offset: string; +// limit: string; +// }, +// sortOptions: Record, +// userId: string, +// ) => { +// let table: PgTableWithColumns<{ +// columns: { +// id: PgColumn; +// name: PgColumn; +// }; +// dialect: 'pg'; +// name: 'memberships'; +// schema: undefined; +// }>; + +// switch (entity) { +// case 'ORGANIZATION': +// table = organizationsTable; +// break; +// case 'PROJECT': +// table = projectsTable; +// break; +// default: +// throw new Error('Invalid entity type'); +// } + +// const filter: SQL | undefined = q ? ilike(table.name, `%${q}%`) : undefined; + +// const entitiesQuery = db.select().from(table).where(filter); + +// const [{ total }] = await db.select({ total: count() }).from(entitiesQuery.as('entities')); + +// const memberships = db +// .select() +// .from(membershipsTable) +// .where(and(eq(membershipsTable.userId, userId), eq(membershipsTable.type, entity))) +// .as('memberships'); + +// const orderColumn = getOrderColumn(sortOptions, sort, table.id, order); + +// const countsQuery = await counts(entity); + +// const entities = await db +// .select({ +// entity: table, +// membership: membershipsTable, +// admins: countsQuery.admins, +// members: countsQuery.members, +// }) +// .from(entitiesQuery.as('entities')) +// .leftJoin(memberships, eq(table.id, memberships.organizationId)) +// .leftJoin(countsQuery, eq(table.id, countsQuery.id)) +// .orderBy(orderColumn) +// .limit(Number(limit)) +// .offset(Number(offset)); + +// return { +// entities, +// total, +// }; +// }; diff --git a/backend/src/lib/ascii.ts b/backend/src/lib/ascii.ts new file mode 100644 index 000000000..5b0f4985d --- /dev/null +++ b/backend/src/lib/ascii.ts @@ -0,0 +1,10 @@ +const ascii = () => { + console.info(' _ _ '); + console.info(' ▒▓█████▓▒ ___ ___| | | __ _ '); + console.info(' ▒▓█ █▓▒ / __/ _ \\ | |/ _` | '); + console.info(' ▒▓█ █▓▒ | (_| __/ | | (_| | '); + console.info(' ▒▓█████▓▒ \\___\\___|_|_|\\__,_| '); + console.info(''); +}; + +export default ascii; diff --git a/backend/src/lib/common-responses.ts b/backend/src/lib/common-responses.ts new file mode 100644 index 000000000..d402bd5a7 --- /dev/null +++ b/backend/src/lib/common-responses.ts @@ -0,0 +1,69 @@ +import type { createRoute } from '@hono/zod-openapi'; +import { z } from 'zod'; +import { failWithErrorSchema, errorSchema } from './common-schemas'; + +type Responses = Parameters[0]['responses']; + +export const successWithoutDataSchema = z.object({ + success: z.boolean(), +}); + +export const successWithDataSchema = (schema: T) => z.object({ success: z.boolean(), data: schema }); + +export const successWithPaginationSchema = (schema: T) => + z.object({ + success: z.boolean(), + data: z.object({ + items: schema.array(), + total: z.number(), + }), + }); + +export const successWithErrorsSchema = () => + z.object({ + success: z.boolean(), + errors: z.array(errorSchema), + }); + +export const errorResponses = { + 400: { + description: 'Bad request: problem processing request.', + content: { + 'application/json': { + schema: failWithErrorSchema, + }, + }, + }, + 401: { + description: 'Unauthorized: authentication required.', + content: { + 'application/json': { + schema: failWithErrorSchema, + }, + }, + }, + 403: { + description: 'Forbidden: insufficient permissions.', + content: { + 'application/json': { + schema: failWithErrorSchema, + }, + }, + }, + 404: { + description: 'Not found: resource does not exist.', + content: { + 'application/json': { + schema: failWithErrorSchema, + }, + }, + }, + 500: { + description: 'Server error: something went wrong.', + content: { + 'application/json': { + schema: failWithErrorSchema, + }, + }, + }, +} satisfies Responses; diff --git a/backend/src/lib/common-schemas.ts b/backend/src/lib/common-schemas.ts new file mode 100644 index 000000000..fa84cf28b --- /dev/null +++ b/backend/src/lib/common-schemas.ts @@ -0,0 +1,108 @@ +import { config } from 'config'; +import { z } from 'zod'; + +export const passwordSchema = z.string().min(8).max(100); + +export const cookieSchema = z.string(); + +export const entityTypeSchema = z.enum(config.entityTypes); +export const contextEntityTypeSchema = z.enum(config.contextEntityTypes); + +export const idSchema = z.string(); + +export const slugSchema = z.string(); + +export const idOrSlugSchema = idSchema.or(slugSchema); + +export const tokenSchema = z.object({ + token: z.string(), +}); + +export const errorSchema = z.object({ + message: z.string(), + type: z.string(), + status: z.number(), + severity: z.string(), + entityType: entityTypeSchema.optional(), + logId: z.string().optional(), + path: z.string().optional(), + method: z.string().optional(), + timestamp: z.string().optional(), + usr: z.string().optional(), + org: z.string().optional(), +}); + +export const failWithErrorSchema = z.object({ + success: z.boolean().default(false), + error: errorSchema, +}); + +const offsetRefine = (value: string | undefined) => Number(value) >= 0; +const limitRefine = (value: string | undefined) => Number(value) > 0; + +export const paginationQuerySchema = z.object({ + q: z.string().optional(), + sort: z.enum(['createdAt']).default('createdAt').optional(), + order: z.enum(['asc', 'desc']).default('asc').optional(), + offset: z.string().default('0').optional().refine(offsetRefine, 'Must be number greater or equal to 0'), + limit: z.string().default('50').optional().refine(limitRefine, 'Must be number greater than 0'), +}); + +export const idsQuerySchema = z.object({ + ids: z.union([z.string(), z.array(z.string())]), +}); + +export const validSlugSchema = z + .string() + .min(2) + .max(100) + .refine( + (s) => /^[a-z0-9]+(-{0,3}[a-z0-9]+)*$/i.test(s), + 'Slug may only contain alphanumeric characters or up to three hyphens, and cannot begin or end with a hyphen.', + ) + .transform((str) => str.toLowerCase().trim()); + +export const validDomainsSchema = z + .array( + z + .string() + .min(4) + .max(100) + .refine( + (s) => /^[a-z0-9].*[a-z0-9]$/i.test(s) && s.includes('.'), + 'Domain must not contain @, no special chars and at least one dot (.) in between.', + ) + .transform((str) => str.toLowerCase().trim()), + ) + .optional(); + +export const entityParamSchema = z.object({ + idOrSlug: idOrSlugSchema, +}); + +export const membershipsCountSchema = z.object({ + memberships: z.object({ + admins: z.number(), + members: z.number(), + total: z.number(), + }), +}); + +export const imageUrlSchema = z + .string() + .url() + .refine((url) => new URL(url).search === '', 'Search params not allowed'); + +export const nameSchema = z + .string() + .min(2) + .max(100) + .refine((s) => /^[a-z0-9 ,.'-]+$/i.test(s), "Name may only contain letters, numbers, spaces and these characters: ,.'-"); + +export const colorSchema = z + .string() + .min(3) + .max(7) + .regex(/^#(?:[0-9a-fA-F]{3}){1,2}$/, 'Color may only contain letters, numbers & starts with #'); + +export const validUrlSchema = z.string().refine((url: string) => url.startsWith('https'), 'URL must start with https://'); diff --git a/backend/src/lib/counts.ts b/backend/src/lib/counts.ts new file mode 100644 index 000000000..3fe6d30a0 --- /dev/null +++ b/backend/src/lib/counts.ts @@ -0,0 +1,51 @@ +import { count, eq, sql } from 'drizzle-orm'; +import { db } from '../db/db'; +import { membershipsTable } from '../db/schema/memberships'; +import type { Entity } from '../types/common'; + +const getQuery = (entity: Entity) => { + let columnName: keyof typeof membershipsTable; + + switch (entity) { + case 'ORGANIZATION': + columnName = 'organizationId'; + break; + case 'PROJECT': + columnName = 'projectId'; + break; + default: + throw new Error(`Invalid entity type: ${entity}`); + } + + return db + .select({ + id: membershipsTable[columnName], + admins: count(sql`CASE WHEN ${membershipsTable.role} = 'ADMIN' THEN 1 ELSE NULL END`).as('admins'), + members: count().as('members'), + }) + .from(membershipsTable) + .where(eq(membershipsTable.type, entity)) + .groupBy(membershipsTable[columnName]) + .as('counts'); +}; + +export function counts( + entity: Entity, + id?: T, +): T extends string ? Promise<{ memberships: { admins: number; members: number; total: number } }> : Promise>; +export async function counts(entity: Entity, id?: string | undefined) { + const query = getQuery(entity); + + if (id) { + const [{ admins, members }] = await db.select().from(query).where(eq(query.id, id)); + return { + memberships: { + admins, + members, + total: members, + }, + }; + } + + return query; +} diff --git a/backend/src/lib/default-hook.ts b/backend/src/lib/default-hook.ts new file mode 100644 index 000000000..83fd494fa --- /dev/null +++ b/backend/src/lib/default-hook.ts @@ -0,0 +1,21 @@ +import type { Hook } from '@hono/zod-openapi'; +import { ZodError } from 'zod'; +import { logEvent } from '../middlewares/logger/log-event'; +import type { Env } from '../types/common'; + +const defaultHook: Hook = (result, ctx) => { + if (!result.success && result.error instanceof ZodError) { + logEvent( + 'Validation error', + { + error: result.error.issues[0].message, + path: result.error.issues[0].path[0], + }, + 'info', + ); + + return ctx.json({ success: false, error: result.error.issues[0].message }, 400); + } +}; + +export default defaultHook; diff --git a/backend/src/lib/docs.ts b/backend/src/lib/docs.ts new file mode 100644 index 000000000..7a97dc6f4 --- /dev/null +++ b/backend/src/lib/docs.ts @@ -0,0 +1,79 @@ +import { apiReference } from '@scalar/hono-api-reference'; +import { config } from 'config'; +import type { CustomHono } from '../types/common'; + +const openAPITags = [ + { name: 'me', description: 'Current user endpoints. They are split from `users` due to a different authorization flow.' }, + { name: 'users', description: '`USER` is also an entity, but NOT a contextual entity.' }, + { + name: 'memberships', + description: + 'Memberships are one-on-one relations between a user and a contextual entity, such as an organization. It contains a role and archived, muted status', + }, + { name: 'organizations', description: 'Organizations - `ORGANIZATION` - are obviously a central `entity`.' }, + { name: 'requests', description: 'Receive public requests such as contact form, newsletter and waitlist requests.' }, + { name: 'general', description: 'Endpoints that overlap multiple entities or are meant to support the system in general.' }, + { + name: 'auth', + description: + 'Multiple authentication methods are included: email/password combination, OAuth with Github. Other Oauth providers and passkey support are work in progress.', + }, + { + name: 'workspaces', + description: + 'App-specific entity (will be split from template). Workspace functions for end-users to personalize how they interact with their projects and the content in each project. Only the creator has access and no other members are possible.', + }, + { + name: 'projects', + description: + 'App-specific entity (will be split from template). Projects - like organizations - can have multiple members and are the primary entity in relation to the content-related resources: tasks, labels and attachments. Because a project can be in multiple workspaces, a relations table is maintained.', + }, +]; + +const docs = (app: CustomHono) => { + const registry = app.openAPIRegistry; + + registry.registerComponent('securitySchemes', 'cookieAuth', { + type: 'apiKey', + in: 'cookie', + name: `${config.slug}-session-v1`, + description: + "Authentication cookie. Copy the cookie from your network tab and paste it here. If you don't have it, you need to sign in or sign up first.", + }); + + app.doc31('/openapi.json', { + servers: [{ url: config.backendUrl }], + info: { + title: `${config.name} API`, + version: 'v1', + description: ` + (ATTENTION: PRERELEASE!) This API documentation is split in modules. Each module relates to a module in the backend codebase. Each module should be at least loosely-coupled, but ideally entirely decoupled. The documentation is based upon zod schemas that are converted to openapi specs using hono middleware: zod-openapi. + + API differentiates between three types of resources: + + 1) page-related resources are called an 'entity' (ie ORGANIZATION or USER) + 2) a subclass are 'contextual entities' (ie ORGANIZATION, not USER) + 3) remaining data objects are simply content-related 'resources'. + + - Content-related resources - called simply 'resources' - dont have an API + they run through the Electric SQL sync engine + - SSE stream is not included in this API documentation + - API design is flat, not nested + `, + }, + openapi: '3.1.0', + tags: openAPITags, + security: [{ cookieAuth: [] }], + }); + + app.get( + '/docs', + apiReference({ + spec: { + url: 'openapi.json', + }, + }), + ); +}; + +export default docs; diff --git a/backend/src/lib/entity.ts b/backend/src/lib/entity.ts new file mode 100644 index 000000000..89a376ccc --- /dev/null +++ b/backend/src/lib/entity.ts @@ -0,0 +1,54 @@ +import { eq, inArray, or } from 'drizzle-orm'; +import { db } from '../db/db'; +import { organizationsTable } from '../db/schema/organizations'; +import { projectsTable } from '../db/schema/projects'; +import { usersTable } from '../db/schema/users'; +import { workspacesTable } from '../db/schema/workspaces'; + +// Create a map to store tables for different resource types +export const entityTables = new Map([ + ['ORGANIZATION', organizationsTable], + ['WORKSPACE', workspacesTable], + ['PROJECT', projectsTable], + ['USER', usersTable], +]); + +/** + * Resolves entity based on ID or Slug and sets the context accordingly. + * @param entityType - The type of the entity. + * @param idOrSlug - The unique identifier (ID or Slug) of the entity. + */ +export const resolveEntity = async (entityType: string, idOrSlug: string) => { + const table = entityTables.get(entityType.toUpperCase()); + + // Return early if table is not available + if (!table) throw new Error(`Invalid entity: ${entityType}`); + + const [entity] = await db + .select() + .from(table) + .where(or(eq(table.id, idOrSlug), eq(table.slug, idOrSlug))); + + return entity; +}; + +/** + * Resolves entities based on their IDs and sets the context accordingly. + * @param entityType - The type of the entity. + * @param ids - An array of unique identifiers (IDs) of the entities. + */ +export const resolveEntities = async (entityType: string, ids: Array) => { + // Get the corresponding table for the entity type + const table = entityTables.get(entityType.toUpperCase()); + + // Return early if table is not available + if (!table) throw new Error(`Invalid entity: ${entityType}`); + + // Validate presence of IDs + if (!Array.isArray(ids) || !ids.length) throw new Error(`Missing or invalid query identifiers for entity: ${entityType}`); + + // Query for multiple entities by IDs + const entities = await db.select().from(table).where(inArray(table.id, ids)); + + return entities; +}; diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 000000000..79ccebbb8 --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,77 @@ +import type { Context } from 'hono'; +import type { ClientErrorStatusCode, ServerErrorStatusCode } from 'hono/utils/http-status'; +import type { z } from 'zod'; +import { logEvent, logtail } from '../middlewares/logger/log-event'; +import type { Entity } from '../types/common'; +import type { errorSchema } from './common-schemas'; +import { i18n } from './i18n'; + +export type HttpErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode; + +export type Severity = 'debug' | 'info' | 'log' | 'warn' | 'error'; + +export type ErrorType = z.infer & { + eventData?: EventData; + name?: Error['name']; +}; + +export type EventData = { + readonly [key: string]: number | string | boolean | null; +}; + +// Create error object and log it if needed +export const createError = ( + ctx: Context, + status: HttpErrorStatus, + type: string, + severity: Severity = 'info', + entityType?: Entity, + eventData?: EventData, + err?: Error, +) => { + const translationKey = `common:error.${type}`; + const message = i18n.t(translationKey); + + const user = ctx.get('user'); + const organization = ctx.get('organization'); + + const error: ErrorType = { + message, + type: type, + status, + severity, + logId: ctx.get('logId'), + path: ctx.req.path, + method: ctx.req.method, + entityType, + usr: user?.id, + org: organization?.id, + }; + + if (err || ['warn', 'error'].includes(severity)) { + const data = { ...error, eventData }; + + logtail[severity](message, undefined, data as unknown as EventData); + console.error(err); + } + // Log significant events with additional data + else if (eventData) logEvent(message, eventData, severity); + + return error; +}; + +// Return error as http response +export const errorResponse = ( + ctx: Context, + status: HttpErrorStatus, + type: string, + severity: Severity = 'info', + entityType?: Entity, + eventData?: EventData, + err?: Error, +) => { + const error: ErrorType = createError(ctx, status, type, severity, entityType, eventData, err); + + // TODO: Review this assignment (as 400) + return ctx.json({ success: false, error }, status as 400); +}; diff --git a/backend/src/lib/i18n.ts b/backend/src/lib/i18n.ts new file mode 100644 index 000000000..faecb1449 --- /dev/null +++ b/backend/src/lib/i18n.ts @@ -0,0 +1,30 @@ +import i18n, { type InitOptions } from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { config } from 'config'; + +export type { ParseKeys } from 'i18next'; + +import enBackend from '../../../locales/en/backend.json'; +import enCommon from '../../../locales/en/common.json'; +import nlBackend from '../../../locales/nl/backend.json'; +import nlCommon from '../../../locales/nl/common.json'; + +// Set up i18n +const initOptions: InitOptions = { + resources: { + en: { backend: enBackend, common: enCommon }, + nl: { backend: nlBackend, common: nlCommon }, + }, + debug: config.debug, + ns: ['backend'], + supportedLngs: config.languages.map((lng) => lng.value), + load: 'languageOnly', + fallbackLng: config.defaultLanguage, + defaultNS: 'backend', +}; + +// Init i18n instance +i18n.use(initReactI18next).init(initOptions); + +export { i18n }; diff --git a/backend/src/lib/imado-url.ts b/backend/src/lib/imado-url.ts new file mode 100644 index 000000000..ad587d476 --- /dev/null +++ b/backend/src/lib/imado-url.ts @@ -0,0 +1,9 @@ +import { ImadoUrl } from '@cellajs/imado'; +import { config } from 'config'; +import { env } from 'env'; + +export const getImadoUrl = new ImadoUrl({ + signUrl: config.privateCDNUrl, + cloudfrontKeyId: env.AWS_CLOUDFRONT_KEY_ID, + cloudfrontPrivateKey: env.AWS_CLOUDFRONT_PRIVATE_KEY, +}); diff --git a/backend/src/lib/nanoid.ts b/backend/src/lib/nanoid.ts new file mode 100644 index 000000000..7c694ed10 --- /dev/null +++ b/backend/src/lib/nanoid.ts @@ -0,0 +1,3 @@ +import { customAlphabet } from 'nanoid'; + +export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789'); diff --git a/backend/src/lib/notification.ts b/backend/src/lib/notification.ts new file mode 100644 index 000000000..88d658ffd --- /dev/null +++ b/backend/src/lib/notification.ts @@ -0,0 +1,41 @@ +import { ChatProviderIdEnum, Novu } from '@novu/node'; +import { config } from 'config'; +import { env } from 'env'; +import { logEvent } from '../middlewares/logger/log-event'; + +export const sendSlackNotification = async (requestFor: string, email: string) => { + try { + if (!env.NOVU_API_KEY) return logEvent('Novu API key is not provided.'); + const novu = new Novu(env.NOVU_API_KEY); + + const subscriber = env.NOVU_SUB_ID || 'subscriber1'; + + // Identify the subscriber. If there is no subscriber with such an ID, create one. If one exists, use it. + await novu.subscribers.identify(subscriber, { + firstName: config.company.name, + email: config.company.email, + }); + + // Set's notification to chosen channel + if (env.NOVU_SLACK_WEBHOOK) { + await novu.subscribers.setCredentials(subscriber, ChatProviderIdEnum.Slack, { + webhookUrl: env.NOVU_SLACK_WEBHOOK, + }); + } + + // Send the notification + novu.trigger('cellaslack', { + to: { + subscriberId: subscriber, + }, + payload: { + requestFor, + email, + }, + }); + + return logEvent('Slack message send successful'); + } catch (err) { + return logEvent('Slack message send failed'); + } +}; diff --git a/backend/src/lib/order-column.ts b/backend/src/lib/order-column.ts new file mode 100644 index 000000000..cc225bed5 --- /dev/null +++ b/backend/src/lib/order-column.ts @@ -0,0 +1,11 @@ +import { type AnyColumn, type SQLWrapper, asc, desc } from 'drizzle-orm'; + +export const getOrderColumn = , U extends keyof T>( + sortOptions: T, + sort: U | undefined, + def: T[U], + order: 'asc' | 'desc' = 'asc', +) => { + const orderFunc = order === 'asc' ? asc : desc; + return orderFunc(sort && sortOptions[sort] ? sortOptions[sort] : def); +}; diff --git a/backend/src/lib/permission-manager.ts b/backend/src/lib/permission-manager.ts new file mode 100644 index 000000000..9ddcc6965 --- /dev/null +++ b/backend/src/lib/permission-manager.ts @@ -0,0 +1,99 @@ +// Import required modules from '@cellajs/permission-manager' +import { + type AccessPolicyConfiguration, + Context, + HierarchicalEntity, + type Membership, + MembershipAdapter, + PermissionManager, + type Subject, + SubjectAdapter, +} from '@cellajs/permission-manager'; + +/** + * Define hierarchical structure for contexts with roles. + */ +const organization = new Context('organization', ['ADMIN', 'MEMBER']); +new Context('workspace', ['ADMIN', 'MEMBER'], new Set([organization])); +new Context('project', ['ADMIN', 'MEMBER'], new Set([organization])); + +/** + * Initialize the PermissionManager and configure access policies. + */ +const permissionManager = new PermissionManager('permissionManager'); + +permissionManager.accessPolicies.configureAccessPolicies(({ subject, contexts }: AccessPolicyConfiguration) => { + // Configure actions based on the subject (organization or workspace) + switch (subject.name) { + case 'organization': + contexts.organization.ADMIN({ create: 1, read: 1, update: 1, delete: 1 }); + contexts.organization.MEMBER({ create: 0, read: 1, update: 0, delete: 0 }); + break; + case 'workspace': + contexts.organization.ADMIN({ create: 1, read: 1, update: 1, delete: 1 }); + contexts.workspace.ADMIN({ create: 0, read: 1, update: 1, delete: 1 }); + contexts.workspace.MEMBER({ create: 0, read: 1, update: 0, delete: 0 }); + break; + case 'project': + contexts.organization.ADMIN({ create: 1, read: 1, update: 1, delete: 1 }); + contexts.project.ADMIN({ create: 0, read: 1, update: 1, delete: 1 }); + contexts.project.MEMBER({ create: 0, read: 1, update: 0, delete: 0 }); + break; + } +}); + +/** + * Adapter for transforming raw membership data into the expected Membership format. + */ +class AdaptedMembershipAdapter extends MembershipAdapter { + /** + * Adapt raw membership data to the Membership format. + * @param memberships - Array of raw membership data. + * @returns Array of adapted Membership objects. + */ + + // biome-ignore lint/suspicious/noExplicitAny: The format of the membership object may vary. + adapt(memberships: any[]): Membership[] { + return memberships.map((m) => ({ + contextName: m.type?.toLowerCase() || '', + contextKey: m[`${m.type?.toLowerCase() || ''}Id`], + roleName: m.role, + ancestors: { + organization: m.organizationId, + workspace: m.workspaceId, + }, + })); + } +} + +/** + * Adapter for transforming raw subject data into the expected Subject format. + */ +class AdaptedSubjectAdapter extends SubjectAdapter { + /** + * Adapt raw subject data to the Subject format. + * @param s - Raw subject data. + * @returns Adapted Subject object. + */ + + // biome-ignore lint/suspicious/noExplicitAny: The format of the subject can vary depending on the subject. + adapt(s: any): Subject { + return { + // TODO: Temporarily retain parent checks... Remove logic once migration is complete and 'entity' property is added to subjects. + name: 'entity' in s ? s.entity.toLowerCase() : 'workspaceId' in s ? 'project' : 'organizationId' in s ? 'workspace' : 'organization', + key: s.id, + ancestors: { + organization: s.organizationId, + workspace: s.workspaceId, + }, + }; + } +} + +// Instantiate adapters to be used in the system +new AdaptedSubjectAdapter(); +new AdaptedMembershipAdapter(); + +// Export the configured PermissionManager instance +export default permissionManager; +export { HierarchicalEntity }; diff --git a/backend/src/lib/route-config.ts b/backend/src/lib/route-config.ts new file mode 100644 index 000000000..15cc21b3c --- /dev/null +++ b/backend/src/lib/route-config.ts @@ -0,0 +1,38 @@ +import { createRoute } from '@hono/zod-openapi'; +import type { MiddlewareHandler } from 'hono'; +import type { NonEmptyArray } from '../types/common'; + +export type RouteOptions = Parameters[0] & { + guard: MiddlewareHandler | NonEmptyArray; +}; + +export type RouteConfig = { + route: ReturnType; + guard: RouteOptions['guard']; +}; + +export type Route< + P extends string, + R extends Omit & { + path: P; + }, +> = ReturnType>>; + +export const createRouteConfig = < + P extends string, + R extends Omit & { + path: P; + }, +>({ + guard, + ...routeConfig +}: R): Route => { + const initGuard = Array.isArray(guard) ? guard : [guard]; + const initMiddleware = routeConfig.middleware ? (Array.isArray(routeConfig.middleware) ? routeConfig.middleware : [routeConfig.middleware]) : []; + const middleware = [...initGuard, ...initMiddleware]; + + return createRoute({ + ...routeConfig, + middleware, + }); +}; diff --git a/backend/src/lib/sse.ts b/backend/src/lib/sse.ts new file mode 100644 index 000000000..88dda1999 --- /dev/null +++ b/backend/src/lib/sse.ts @@ -0,0 +1,16 @@ +import { streams } from '../modules/general'; + +const sendSSE = (userId: string, eventName: string, data: Record): void => { + const stream = streams.get(userId); + if (stream === undefined) return; + stream.writeSSE({ + event: eventName, + data: JSON.stringify(data), + retry: 5000, + }); +}; + +export const sendSSEToUsers = (users: string[] | null, eventName: string, data: Record): void => { + if (!users || users.length === 0) return; + users.map((id) => sendSSE(id, eventName, data)); +}; diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts new file mode 100644 index 000000000..0d10ec8b8 --- /dev/null +++ b/backend/src/lib/utils.ts @@ -0,0 +1,26 @@ +import { env } from 'env'; +import { sign } from 'hono/jwt'; + +interface GenerateTokenOptions { + userId: string; +} + +/** + * Generates a JWT token for Electric. Expires in 1 day. + * + * @param {string} userId - The user ID to include in the token. + * @returns {Promise} - A promise that resolves to the generated JWT token. + */ +export const generateElectricJWTToken = async ({ userId }: GenerateTokenOptions): Promise => { + return await sign( + { + iat: Math.floor(Date.now() / 1000), + iss: 'cella_backend', + aud: 'cella_client', + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // Token expires in 1 day + sub: userId, + }, + env.ELECTRIC_PRIVATE_KEY_ES256, + 'ES256', + ); +}; diff --git a/backend/src/middlewares/README.md b/backend/src/middlewares/README.md new file mode 100644 index 000000000..d0367dd06 --- /dev/null +++ b/backend/src/middlewares/README.md @@ -0,0 +1,16 @@ +# Middlewares +A short introduction into the most important middleware as they are crucial for fast and secure development. + +### Guard +Guard is where cella protects and secures endpoints with middleware. There are currently four options. Except for isPublicAccess, the other three can and should be combined and isAuthenticated always needs to be the first one. For example a `middleware` for an endpoint could be: `[isAuthenticated, isAllowedTo('delete', 'ORGANIZATION'), isSystemAdmin]` + +* isPublicAccess +* isAuthenticated +* isAllowedTo +* isSystemAdmin + +### Rate limuter + +### Logger + + diff --git a/backend/src/middlewares/guard/index.ts b/backend/src/middlewares/guard/index.ts new file mode 100644 index 000000000..43b0c8cc3 --- /dev/null +++ b/backend/src/middlewares/guard/index.ts @@ -0,0 +1,25 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { errorResponse } from '../../lib/errors'; +import isAllowedTo from './is-allowed-to'; +import isAuthenticated from './is-authenticated'; +import splitByAllowance from './split-by-allowance'; + +export { isAuthenticated }; +export { isAllowedTo }; +export { splitByAllowance }; + +export const isSystemAdmin: MiddlewareHandler = async (ctx: Context, next) => { + // Extract user + const user = ctx.get('user'); + + // TODO: Add more checks for system admin, such as IP address, 2FA etc. + if (!user || !user.role.includes('ADMIN')) { + return errorResponse(ctx, 403, 'forbidden', 'warn', undefined, { user: user.id }); + } + + await next(); +}; + +export const isPublicAccess: MiddlewareHandler = async (_, next) => { + await next(); +}; diff --git a/backend/src/middlewares/guard/is-allowed-to.ts b/backend/src/middlewares/guard/is-allowed-to.ts new file mode 100644 index 000000000..50982008a --- /dev/null +++ b/backend/src/middlewares/guard/is-allowed-to.ts @@ -0,0 +1,147 @@ +import { eq } from 'drizzle-orm'; +import type { Context, MiddlewareHandler } from 'hono'; +import { db } from '../../db/db'; +import { membershipsTable } from '../../db/schema/memberships'; +import { resolveEntity } from '../../lib/entity'; +import { errorResponse } from '../../lib/errors'; +import permissionManager, { HierarchicalEntity } from '../../lib/permission-manager'; +import type { ContextEntity, Env } from '../../types/common'; +import { logEvent } from '../logger/log-event'; + +export type PermissionAction = 'create' | 'update' | 'read' | 'write'; + +/** + * Middleware to protect routes by checking user permissions. + * @param action - The action to be performed (e.g., 'read', 'write'). + * @param entityType - The type of the entity (e.g., 'USER', 'ORGANIZATION'). + * @returns MiddlewareHandler to protect routes based on user permissions. + */ +const isAllowedTo = + // biome-ignore lint/suspicious/noExplicitAny: it's required to use `any` here + (action: PermissionAction, entityType: ContextEntity): MiddlewareHandler => + async (ctx: Context, next) => { + // Extract user + const user = ctx.get('user'); + + // Retrieve the context of the entity to be authorized (e.g., 'organization', 'workspace') + const contextEntity = await getEntityContext(ctx, entityType); + + // Check if user or context is missing + if (!contextEntity || !user) { + return errorResponse(ctx, 404, 'not_found', 'warn', entityType, { + user: user?.id, + id: contextEntity?.id || '', + }); + } + + // Fetch user's memberships from the database + const memberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); + + // Check if the user is allowed to perform the action in the given context + const isAllowed = permissionManager.isPermissionAllowed(memberships, action, contextEntity); + + // If not allowed and not admin, return forbidden + if (!isAllowed && user.role !== 'ADMIN') { + return errorResponse(ctx, 403, 'forbidden', 'warn', entityType, { user: user.id, id: contextEntity.id }); + } + + // Store user memberships and authorized context entity in hono ctx + ctx.set('memberships', memberships); + ctx.set(entityType.toLowerCase(), contextEntity); + + // Log user allowance in the context + logEvent(`User is allowed to ${action} ${contextEntity.entity}`, { user: user.id, id: contextEntity.id }); + + await next(); + }; + +/** + * Get the context based on the entity type. + * Handles resolve for both direct entity operations (retrieval, update, deletion) and contextual operations (fetching child entities). + * @param ctx - The context object containing request and response details. + * @param entityType - The type of the entity (e.g., 'ORGANIZATION', 'WORKSPACE'). + */ + +// biome-ignore lint/suspicious/noExplicitAny: Prevent assignable errors +async function getEntityContext(ctx: any, entityType: ContextEntity) { + // Check if entity is configured; if not, return early + if (!HierarchicalEntity.instanceMap.has(entityType.toLowerCase())) { + return; + } + + const idOrSlug = ctx.req.param('idOrSlug') || ctx.req.query(`${entityType.toLowerCase()}Id`); + + if (idOrSlug) { + // Handles resolve for direct entity operations (retrieval, update, deletion) based on unique identifier (ID or Slug). + return await resolveEntity(entityType, idOrSlug); + } + + // Generate a context using the lowest parent for entity operations, such as fetching or creating child entities + return await createEntityContext(entityType, ctx); +} + +/** + * Creates a context based on the lowest parent for an entity. + * @param entityType - The type of the entity. + * @param ctx - The context object containing request and response details. + */ + +// biome-ignore lint/suspicious/noExplicitAny: Prevent assignable errors +async function createEntityContext(entityType: ContextEntity, ctx: any) { + const entity = HierarchicalEntity.instanceMap.get(entityType.toLowerCase()); + + // Return early if entity is not available + if (!entity) return; + + // Extract payload from request body, with try/catch to handle potential empty body bug in HONO (see: https://github.com/honojs/hono/issues/2651) + // biome-ignore lint/suspicious/noExplicitAny: Using 'any' here because the payload can be of any type + let payload: Record = {}; + try { + payload = await ctx.req.json(); + } catch { + payload = {}; + } + + // TODO make this more clear and explicit and tpye safe + // Initialize context to store the custom created context entity based on the lowest possible ancestor + const context: Record = { entity: entityType }; + + // Variable to hold the lowest ancestor found + // biome-ignore lint/suspicious/noExplicitAny: The lowest ancestor can be of different entity types (e.g., organization, workspace, project) or undefined + let lowestAncestor: any; + + // Iterate over ancestors (from lowest to highest) and determine the lowest ancestor available + for (const ancestor of entity.descSortedAncestors) { + // Continue searching for the lowest ancestor if not found yet + if (!lowestAncestor) { + // Check if ancestor identifier is provided in params or query + let lowestAncestorIdOrSlug = ( + ctx.req.param(ancestor.name) || + ctx.req.param(`${ancestor.name}Id`) || + ctx.req.query(ancestor.name) || + ctx.req.query(`${ancestor.name}Id`) + )?.toLowerCase(); + + // If not found in params or query, check if it's provided in the request body + if (!lowestAncestorIdOrSlug && payload) { + lowestAncestorIdOrSlug = payload[`${ancestor.name}Id`]; + } + + // If identifier is found, resolve the lowest ancestor + if (lowestAncestorIdOrSlug) { + lowestAncestor = await resolveEntity(ancestor.name, lowestAncestorIdOrSlug); + if (lowestAncestor) { + // Set the lowest ancestor as parent in context + context[`${ancestor.name}Id`] = lowestAncestor.id; + } + } + } else if (lowestAncestor[`${ancestor.name}Id`]) { + // Resolve ancestors by the parents of the lowest ancestor + context[`${ancestor.name}Id`] = lowestAncestor[`${ancestor.name}Id`]; + } + } + + return context; +} + +export default isAllowedTo; diff --git a/backend/src/middlewares/guard/is-authenticated.ts b/backend/src/middlewares/guard/is-authenticated.ts new file mode 100644 index 000000000..a482f6ce0 --- /dev/null +++ b/backend/src/middlewares/guard/is-authenticated.ts @@ -0,0 +1,36 @@ +import type { MiddlewareHandler } from 'hono'; +import { auth as luciaAuth } from '../../db/lucia'; +import { errorResponse } from '../../lib/errors'; +import { i18n } from '../../lib/i18n'; +import { removeSessionCookie } from '../../modules/auth/helpers/cookies'; + +const isAuthenticated: MiddlewareHandler = async (ctx, next) => { + const cookieHeader = ctx.req.raw.headers.get('Cookie'); + const sessionId = luciaAuth.readSessionCookie(cookieHeader ?? ''); + + if (!sessionId) { + removeSessionCookie(ctx); + // t('common:error.invalid_session.text') + return errorResponse(ctx, 401, 'no_session', 'warn'); + } + + const { session, user } = await luciaAuth.validateSession(sessionId); + + if (!session) { + removeSessionCookie(ctx); + return errorResponse(ctx, 401, 'no_session', 'warn'); + } + + if (session.fresh) { + const sessionCookie = luciaAuth.createSessionCookie(session.id); + ctx.header('Set-Cookie', sessionCookie.serialize()); + } + + ctx.set('user', user); + + // TODO: Does this affect perf? + await i18n.changeLanguage(user.language); + await next(); +}; + +export default isAuthenticated; diff --git a/backend/src/middlewares/guard/split-by-allowance.ts b/backend/src/middlewares/guard/split-by-allowance.ts new file mode 100644 index 000000000..b51ca0307 --- /dev/null +++ b/backend/src/middlewares/guard/split-by-allowance.ts @@ -0,0 +1,69 @@ +import { eq } from 'drizzle-orm'; +import type { Context, MiddlewareHandler } from 'hono'; +import { db } from '../../db/db'; +import { membershipsTable } from '../../db/schema/memberships'; +import { resolveEntities } from '../../lib/entity'; +import { errorResponse } from '../../lib/errors'; +import permissionManager from '../../lib/permission-manager'; +import type { Entity, Env } from '../../types/common'; +import { logEvent } from '../logger/log-event'; + +/** + * Middleware that splits a list of IDs into allowed and disallowed by checking user permissions. + * @param {string} action - The action to be performed (e.g., 'update', 'delete'). + * @param {string} entityType - The type of the entity (e.g., 'ORGANIZATION', 'WORKSPACE'). + * @returns {MiddlewareHandler} MiddlewareHandler to protect routes based on user permissions. + */ +const splitByAllowance = + // biome-ignore lint/suspicious/noExplicitAny: it's required to use `any` here + (action: string, entityType: string): MiddlewareHandler => + async (ctx: Context, next) => { + // Extract user + const user = ctx.get('user'); + + // Convert the ids to an array + const rawIds = ctx.req.query('ids'); + const ids = (Array.isArray(rawIds) ? rawIds : [rawIds]).map(String); + + // Check if ids are missing + if (!rawIds || !ids.length) { + return errorResponse(ctx, 404, 'not_found', 'warn', entityType.toUpperCase() as Entity, { user: user?.id }); + } + + // Resolve ids + const entities = await resolveEntities(entityType, ids); + + // Fetch user's memberships from the database + const memberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); + + // Logic to split ids based on permissions + const allowedIds: string[] = []; + const disallowedIds: string[] = []; + + for (const entity of entities) { + const isAllowed = permissionManager.isPermissionAllowed(memberships, action, entity); + + if (!isAllowed && user.role !== 'ADMIN') { + disallowedIds.push(entity.id); + } else { + allowedIds.push(entity.id); + } + } + + // Check if user or context is missing + if (!allowedIds.length) { + return errorResponse(ctx, 403, 'forbidden', 'warn', entityType.toUpperCase() as Entity, { user: user.id }); + } + + // Attach the split IDs to the context + ctx.set('memberships', memberships); + ctx.set('allowedIds', allowedIds); + ctx.set('disallowedIds', disallowedIds); + + // Log user allowance in the context + logEvent(`User is allowed to ${action} a list of ${entityType}s`, { user: user.id }); + + await next(); + }; + +export default splitByAllowance; diff --git a/backend/src/middlewares/index.ts b/backend/src/middlewares/index.ts new file mode 100644 index 000000000..0e9bcea5e --- /dev/null +++ b/backend/src/middlewares/index.ts @@ -0,0 +1,58 @@ +import { sentry } from '@hono/sentry'; +import { config } from 'config'; +import { cors } from 'hono/cors'; +import { csrf } from 'hono/csrf'; +import { secureHeaders } from 'hono/secure-headers'; +import { CustomHono } from '../types/common'; +import { logEvent } from './logger/log-event'; +import { logger } from './logger/logger'; +import { rateLimiter } from './rate-limiter'; + +const app = new CustomHono(); + +// Secure headers +app.use('*', secureHeaders()); + +// Sentry +app.use( + '*', + sentry({ + dsn: config.sentryDsn, + }), +); + +// Health check for render.com +app.get('/ping', (c) => c.text('pong')); + +// TODO - Add a middleware to check if the user is a bot +// Prevent crawlers from causing log spam +// app.use(async (ctx, next) => { +// if (!isbot(ctx.req.header('user-agent'))) await next(); +// return errorResponse(ctx, 403, 'user_maybe_bot', 'warn'); +// }); + +// Logger +app.use('*', logger(logEvent as unknown as Parameters[0])); + +// CORS +app.use( + '*', + cors({ + origin: config.frontendUrl, + credentials: true, + allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'], + allowHeaders: [], + }), +); + +// CSRF protection +app.use( + '*', + csrf({ + origin: config.frontendUrl, + }), +); + +// Rate limiter +app.use('*', rateLimiter({ points: 50, duration: 60 * 60, blockDuration: 60 * 30, keyPrefix: 'common_fail' }, 'fail')); +export default app; diff --git a/backend/src/middlewares/logger/log-event.ts b/backend/src/middlewares/logger/log-event.ts new file mode 100644 index 000000000..0ddb04985 --- /dev/null +++ b/backend/src/middlewares/logger/log-event.ts @@ -0,0 +1,15 @@ +import { Logtail } from '@logtail/node'; +import { env } from 'env'; +import type { EventData, Severity } from '../../lib/errors'; + +export const logtail = new Logtail(env.LOGTAIL_TOKEN || 'test', {}); + +export const logEvent = (message: string, eventData?: EventData, severity: Severity = 'info') => { + if (eventData) { + console[severity](message, eventData); + logtail[severity](message, undefined, eventData); + } else { + console[severity](message); + logtail[severity](message); + } +}; diff --git a/backend/src/middlewares/logger/logger.ts b/backend/src/middlewares/logger/logger.ts new file mode 100644 index 000000000..8acfb01ea --- /dev/null +++ b/backend/src/middlewares/logger/logger.ts @@ -0,0 +1,58 @@ +import type { MiddlewareHandler } from 'hono/types'; +import { nanoid } from '../../lib/nanoid'; + +enum LogPrefix { + Outgoing = 'res', + Incoming = 'req', + Error = 'err', +} + +const humanize = (times: string[]) => { + const [delimiter, separator] = [',', '.']; + + const orderTimes = times.map((v) => v.replace(/(\d)(?=(\d\d\d)+(?!\d))/g, `$1${delimiter}`)); + + return orderTimes.join(separator); +}; + +const time = (start: number) => { + const delta = Date.now() - start; + return humanize([delta < 1000 ? `${delta}ms` : `${Math.round(delta / 1000)}s`]); +}; + +type PrintFunc = (str: string, ...rest: string[]) => void; + +function log(fn: PrintFunc, prefix: string, logId: string, method: string, path: string, status = 0, elapsed?: string, user?: string, org?: string) { + const out = + prefix === LogPrefix.Incoming + ? `${prefix} ${logId} ${method} ${path}` + : `${prefix} ${logId} ${method} ${path} ${status} ${elapsed} ${user}@${org}`; + fn(out); +} + +export const logger = (fn: PrintFunc = console.info): MiddlewareHandler => { + return async function logger(c, next) { + const { method } = c.req; + + // Generate logId and set it so we can use it to match error reports + const logId = nanoid(); + c.set('logId', logId); + + // Show path with search params + const stripUrl = c.req.raw.url.replace(/(https?:\/\/)?([^\/]+)/, '').slice(0, 150); + + // Log incoming + log(fn, LogPrefix.Incoming, logId, method, stripUrl); + + const start = Date.now(); + + await next(); + + // Add logging for user and organization ids + const user = c.get('user')?.id || 'na'; + const org = c.get('organization')?.id || 'na'; + + // Log outgoing + log(fn, LogPrefix.Outgoing, logId, method, stripUrl, c.res.status, time(start), user, org); + }; +}; diff --git a/backend/src/middlewares/rate-limiter/index.ts b/backend/src/middlewares/rate-limiter/index.ts new file mode 100644 index 000000000..b6dc98478 --- /dev/null +++ b/backend/src/middlewares/rate-limiter/index.ts @@ -0,0 +1,105 @@ +import type { MiddlewareHandler } from 'hono'; + +import { type IRateLimiterPostgresOptions, RateLimiterPostgres, RateLimiterRes } from 'rate-limiter-flexible'; +import { errorResponse } from '../../lib/errors'; + +import { queryClient } from '../../db/db'; +import type { Env } from '../../types/common'; + +type RateLimiterMode = 'success' | 'fail' | 'limit'; + +/* + * This file contains the implementation of a rate limiter middleware. + * It uses the `rate-limiter-flexible` library to limit the number of requests per user or IP address. + * https://github.com/animir/node-rate-limiter-flexible#readme + * The rate limiter is implemented as a class `RateLimiter` that extends `RateLimiterPostgres`. + * + * The 'success' mode decreases the available points for the user or IP address on successful requests. + * The 'fail' (default mode does the same but for failed requests. + * The 'limit' mode consumes points for each request without blocking. + * + * Additionally, there is a separate rate limiter for sign-in requests that limits the number of failed attempts per IP address and username. + */ + +const getUsernameIPkey = (username?: string, ip?: string) => `${username}_${ip}`; + +class RateLimiter extends RateLimiterPostgres { + public middleware(mode: RateLimiterMode = 'fail'): MiddlewareHandler { + if (mode === 'success' || mode === 'fail') { + this.points = this.points - 1; + } + + return async (ctx, next) => { + const ipAddr = ctx.req.header('x-forwarded-for'); + // biome-ignore lint/suspicious/noExplicitAny: it's required to use `any` here + const body = ctx.req.header('content-type') === 'application/json' ? ((await ctx.req.raw.clone().json()) as any) : undefined; + const user = ctx.get('user'); + const username = body?.email || user?.id; + + if (!ipAddr && !username) { + return next(); + } + + const usernameIPkey = getUsernameIPkey(username, ipAddr); + + const res = await this.get(usernameIPkey); + + let retrySecs = 0; + + // Check if IP or Username + IP is already blocked + if (res !== null && res.consumedPoints > this.points) { + retrySecs = Math.round(res.msBeforeNext / 1000) || 1; + } + + if (retrySecs > 0) { + ctx.header('Retry-After', String(retrySecs)); + return errorResponse(ctx, 429, 'too_many_requests', 'warn', undefined, { usernameIPkey }); + } + + if (mode === 'limit') { + try { + await this.consume(usernameIPkey); + } catch (rlRejected) { + if (rlRejected instanceof RateLimiterRes) { + ctx.header('Retry-After', String(Math.round(rlRejected.msBeforeNext / 1000) || 1)); + return errorResponse(ctx, 429, 'too_many_requests', 'warn', undefined, { usernameIPkey }); + } + + throw rlRejected; + } + } + + await next(); + + if (ctx.res.status === 200) { + if (mode === 'success') { + try { + await this.consume(usernameIPkey); + } catch {} + } else if (mode === 'fail') { + await this.delete(usernameIPkey); + } + } else if (mode === 'fail') { + try { + await this.consume(usernameIPkey); + } catch {} + } + }; + } +} + +// Default options to limit fail requests ('fail' mode) +const defaultOptions = { + points: 5, // 5 requests + duration: 60 * 60, // within 1 hour + blockDuration: 60 * 10, // Block for 10 minutes +}; + +export const rateLimiter = (options: Omit = defaultOptions, mode: RateLimiterMode = 'fail') => + new RateLimiter({ + ...options, + tableName: 'rate_limits', + storeClient: queryClient, + }).middleware(mode); + +export const authRateLimiter = rateLimiter({ points: 5, duration: 60 * 60, blockDuration: 60 * 10, keyPrefix: 'auth_fail' }, 'fail'); diff --git a/backend/src/middlewares/rate-limiter/sign-in.ts b/backend/src/middlewares/rate-limiter/sign-in.ts new file mode 100644 index 000000000..cb6b66fb3 --- /dev/null +++ b/backend/src/middlewares/rate-limiter/sign-in.ts @@ -0,0 +1,76 @@ +import type { MiddlewareHandler } from 'hono'; + +import { RateLimiterPostgres } from 'rate-limiter-flexible'; +import { errorResponse } from '../../lib/errors'; + +import { queryClient } from '../../db/db'; +import type { Env } from '../../types/common'; + +const getUsernameIPkey = (username?: string, ip?: string) => `${username}_${ip}`; + +// Sign in rate limiter +const maxWrongAttemptsByIPperDay = 100; +const maxConsecutiveFailsByUsernameAndIP = 5; + +const limiterSlowBruteByIP = new RateLimiterPostgres({ + storeClient: queryClient, + tableName: 'rate_limits', + keyPrefix: 'login_fail_ip_per_day', + points: maxWrongAttemptsByIPperDay, + duration: 60 * 60 * 24, + blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day +}); + +const limiterConsecutiveFailsByUsernameAndIP = new RateLimiterPostgres({ + storeClient: queryClient, + tableName: 'rate_limits', + keyPrefix: 'login_fail_consecutive_username_and_ip', + points: maxConsecutiveFailsByUsernameAndIP, + duration: 60 * 60, // Store number for 1 hour since first fail + blockDuration: 60 * 5, // Block for 5 min +}); + +export const signInRateLimiter = (): MiddlewareHandler => async (ctx, next) => { + const ipAddr = ctx.req.header('x-forwarded-for')?.split(',')[0] || ''; + // biome-ignore lint/suspicious/noExplicitAny: it's required to use `any` here + const body = (await ctx.req.raw.clone().json()) as any; + + if (!body.email || !ipAddr) { + return next(); + } + + const usernameIPkey = getUsernameIPkey(body.email, ipAddr); + + const [resUsernameAndIP, resSlowByIP] = await Promise.all([ + limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey), + limiterSlowBruteByIP.get(ipAddr), + ]); + + let retrySecs = 0; + + // Check if IP or Username + IP is already blocked + if (resSlowByIP !== null && resSlowByIP.consumedPoints > maxWrongAttemptsByIPperDay) { + retrySecs = Math.round(resSlowByIP.msBeforeNext / 1000) || 1; + } else if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > maxConsecutiveFailsByUsernameAndIP) { + retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1; + } + + if (retrySecs > 0) { + ctx.header('Retry-After', String(retrySecs)); + return errorResponse(ctx, 429, 'too_many_requests', 'warn', undefined, { usernameIPkey }); + } + + await limiterSlowBruteByIP.consume(ipAddr); + + await next(); + + if (ctx.res.status === 401) { + try { + await limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey); + } catch (error) { + return errorResponse(ctx, 429, 'too_many_requests', 'warn', undefined, { usernameIPkey }); + } + } else { + await limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey); + } +}; diff --git a/backend/src/modules/README.md b/backend/src/modules/README.md new file mode 100644 index 000000000..383d61ea4 --- /dev/null +++ b/backend/src/modules/README.md @@ -0,0 +1,11 @@ +# Backend modules +Write your app-specific code into new modules to easily merge updates of core modules. + +### Core modules +* me +* users +* memberships +* organizations +* requests +* general +* auth diff --git a/backend/src/modules/auth/helpers/cookies.ts b/backend/src/modules/auth/helpers/cookies.ts new file mode 100644 index 000000000..87082cf60 --- /dev/null +++ b/backend/src/modules/auth/helpers/cookies.ts @@ -0,0 +1,38 @@ +import { config } from 'config'; +import { eq } from 'drizzle-orm'; +import type { Context } from 'hono'; +import { setCookie as baseSetCookie } from 'hono/cookie'; +import type { User } from 'lucia'; +import { db } from '../../../db/db'; +import { auth } from '../../../db/lucia'; +import { usersTable } from '../../../db/schema/users'; +import { logEvent } from '../../../middlewares/logger/log-event'; + +const isProduction = config.mode === 'production'; + +export const setCookie = (ctx: Context, name: string, value: string) => + baseSetCookie(ctx, name, value, { + secure: isProduction, // set `Secure` flag in HTTPS + path: '/', + domain: isProduction ? config.domain : undefined, + httpOnly: true, + sameSite: isProduction ? 'lax' : 'lax', + maxAge: 60 * 10, // 10 min + }); + +export const setSessionCookie = async (ctx: Context, userId: User['id'], strategy: string) => { + const session = await auth.createSession(userId, {}); + const sessionCookie = auth.createSessionCookie(session.id); + + const lastSignInAt = new Date(); + await db.update(usersTable).set({ lastSignInAt }).where(eq(usersTable.id, userId)); + + logEvent('User signed in', { user: userId, strategy: strategy }); + + ctx.header('Set-Cookie', sessionCookie.serialize()); +}; + +export const removeSessionCookie = (ctx: Context) => { + const sessionCookie = auth.createBlankSessionCookie(); + ctx.header('Set-Cookie', sessionCookie.serialize()); +}; diff --git a/backend/src/modules/auth/helpers/oauth.ts b/backend/src/modules/auth/helpers/oauth.ts new file mode 100644 index 000000000..aa324ec06 --- /dev/null +++ b/backend/src/modules/auth/helpers/oauth.ts @@ -0,0 +1,108 @@ +import { and, eq } from 'drizzle-orm'; +import type { Context } from 'hono'; +import { getCookie } from 'hono/cookie'; +import { oauthAccountsTable } from '../../../db/schema/oauth-accounts'; +import { type InsertUserModel, usersTable } from '../../../db/schema/users'; +import { setCookie, setSessionCookie } from './cookies'; + +import { config } from 'config'; +import type { User } from 'lucia'; +import slugify from 'slugify'; +import { db } from '../../../db/db'; +import { logEvent } from '../../../middlewares/logger/log-event'; +import type { OauthProviderOptions } from '../../../types/common'; +import { sendVerificationEmail } from './verify-email'; + +// Create a session before redirecting to the oauth provider +export const createSession = (ctx: Context, provider: string, state: string, codeVerifier?: string, redirect?: string) => { + setCookie(ctx, 'oauth_state', state); + + if (codeVerifier) setCookie(ctx, 'oauth_code_verifier', codeVerifier); + if (redirect) setCookie(ctx, 'oauth_redirect', redirect); + + logEvent('User redirected', { strategy: provider }); +}; + +// Get the redirect URL from the cookie or use default +export const getRedirectUrl = (ctx: Context, firstSignIn?: boolean): string => { + const redirectCookie = getCookie(ctx, 'oauth_redirect'); + const redirectCookieUrl = redirectCookie ? decodeURIComponent(redirectCookie) : ''; + let redirectUrl = config.frontendUrl + config.defaultRedirectPath; + + if (redirectCookie) { + redirectUrl = redirectCookieUrl.startsWith('http') ? decodeURIComponent(redirectCookie) : config.frontendUrl + redirectCookieUrl; + } + if (firstSignIn) redirectUrl = config.frontendUrl + config.firstSignInRedirectPath; + return redirectUrl; +}; + +// Insert oauth account into db +export const insertOauthAccount = async (userId: string, providerId: OauthProviderOptions, providerUserId: string) => { + db.insert(oauthAccountsTable).values({ providerId, providerUserId, userId }); +}; + +// Find oauth account in db +export const findOauthAccount = async (providerId: OauthProviderOptions, providerUserId: string) => { + return db + .select() + .from(oauthAccountsTable) + .where(and(eq(oauthAccountsTable.providerId, providerId), eq(oauthAccountsTable.providerUserId, providerUserId))); +}; + +// Find user by email +export const findUserByEmail = async (email: string) => { + return db.select().from(usersTable).where(eq(usersTable.email, email)); +}; + +// Create a slug from email +export const slugFromEmail = (email: string) => { + const [alias] = email.split('@'); + return slugify(alias, { lower: true }); +}; + +// Split full name into first and last name +export const splitFullName = (name: string) => { + const [firstName, lastName] = name.split(' '); + return { firstName: firstName || '', lastName: lastName || '' }; +}; + +// Handle existing user +export const handleExistingUser = async ( + ctx: Context, + existingUser: User, + providerId: OauthProviderOptions, + { + providerUser, + isEmailVerified, + redirectUrl, + }: { + providerUser: Pick; + isEmailVerified: boolean; + redirectUrl: string; + }, +) => { + await insertOauthAccount(existingUser.id, providerId, providerUser.id); + + // Update user with provider data if not already present + await db + .update(usersTable) + .set({ + thumbnailUrl: existingUser.thumbnailUrl || providerUser.thumbnailUrl, + bio: existingUser.bio || providerUser.bio, + emailVerified: isEmailVerified, + firstName: existingUser.firstName || providerUser.firstName, + lastName: existingUser.lastName || providerUser.lastName, + }) + .where(eq(usersTable.id, existingUser.id)); + + // Send verification email if not verified and redirect to verify page + if (!isEmailVerified) { + sendVerificationEmail(providerUser.email.toLowerCase()); + + return ctx.redirect(`${config.frontendUrl}/auth/verify-email`, 302); + } + + await setSessionCookie(ctx, existingUser.id, providerId.toLowerCase()); + + return ctx.redirect(redirectUrl, 302); +}; diff --git a/backend/src/modules/auth/helpers/user.ts b/backend/src/modules/auth/helpers/user.ts new file mode 100644 index 000000000..d390546cc --- /dev/null +++ b/backend/src/modules/auth/helpers/user.ts @@ -0,0 +1,74 @@ +import { config } from 'config'; +import type { Context } from 'hono'; +import { db } from '../../../db/db'; +import { type InsertUserModel, usersTable } from '../../../db/schema/users'; +import { errorResponse } from '../../../lib/errors'; +import { logEvent } from '../../../middlewares/logger/log-event'; +import type { OauthProviderOptions } from '../../../types/common'; +import { checkSlugAvailable } from '../../general/helpers/check-slug'; +import { setSessionCookie } from './cookies'; +import { insertOauthAccount } from './oauth'; +import { sendVerificationEmail } from './verify-email'; + +// Handle creating a user +export const handleCreateUser = async ( + ctx: Context, + data: InsertUserModel, + options?: { + provider?: { + id: OauthProviderOptions; + userId: string; + }; + isEmailVerified?: boolean; + redirectUrl?: string; + }, +) => { + // If sign up is disabled, return an error + if (!config.has.signUp) { + return errorResponse(ctx, 403, 'sign_up_disabled', 'warn', undefined); + } + + // Check if the slug is available + const slugAvailable = await checkSlugAvailable(data.slug); + + try { + // Insert the user into the database + const [user] = await db + .insert(usersTable) + .values({ + id: data.id, + slug: slugAvailable ? data.slug : `${data.slug}-${data.id}`, + firstName: data.firstName, + email: data.email.toLowerCase(), + name: data.name, + language: config.defaultLanguage, + hashedPassword: data.hashedPassword, + }) + .returning(); + + // If a provider is passed, insert the oauth account + if (options?.provider) { + await insertOauthAccount(data.id, options.provider.id, options.provider.userId); + // await setSessionCookie(ctx, data.id, options.provider.id.toLowerCase()); + } + + // If the email is not verified, send a verification email + if (!options?.isEmailVerified) { + sendVerificationEmail(data.email); + } else { + await setSessionCookie(ctx, user.id, 'password'); + } + if (options?.redirectUrl) return ctx.redirect(options.redirectUrl, 302); + return ctx.json({ success: true }, 200); + } catch (error) { + // If the email already exists, return an error + if (error instanceof Error && error.message.startsWith('duplicate key')) { + return errorResponse(ctx, 409, 'email_exists', 'warn', undefined); + } + + const strategy = options?.provider ? options.provider.id : 'EMAIL'; + logEvent('Error creating user', { strategy, errorMessage: (error as Error).message }, 'error'); + + throw error; + } +}; diff --git a/backend/src/modules/auth/helpers/verify-email.ts b/backend/src/modules/auth/helpers/verify-email.ts new file mode 100644 index 000000000..fe9e0de1b --- /dev/null +++ b/backend/src/modules/auth/helpers/verify-email.ts @@ -0,0 +1,19 @@ +import { config } from 'config'; +import { logEvent } from '../../../middlewares/logger/log-event'; +import authRoutesConfig from '../routes'; + +export const sendVerificationEmail = (email: string) => { + try { + fetch(config.backendAuthUrl + authRoutesConfig.sendVerificationEmail.path, { + method: authRoutesConfig.sendVerificationEmail.method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + }), + }); + } catch (err) { + return logEvent('Verification email sending failed'); + } +}; diff --git a/backend/src/modules/auth/index.ts b/backend/src/modules/auth/index.ts new file mode 100644 index 000000000..66d6ce5e1 --- /dev/null +++ b/backend/src/modules/auth/index.ts @@ -0,0 +1,723 @@ +import { render } from '@react-email/render'; +import { eq } from 'drizzle-orm'; +import { generateId } from 'lucia'; +import { TimeSpan, createDate, isWithinExpirationDate } from 'oslo'; +import { VerificationEmail } from '../../../../email/emails/email-verification'; +import { ResetPasswordEmail } from '../../../../email/emails/reset-password'; + +import { Argon2id } from 'oslo/password'; +import { auth } from '../../db/lucia'; + +import { OAuth2RequestError, generateCodeVerifier, generateState } from 'arctic'; +import { deleteCookie, getCookie } from 'hono/cookie'; + +import slugify from 'slugify'; +import { githubAuth, googleAuth, microsoftAuth } from '../../db/lucia'; + +import { createSession, findOauthAccount, findUserByEmail, getRedirectUrl, handleExistingUser, slugFromEmail, splitFullName } from './helpers/oauth'; + +import { config } from 'config'; +import type { z } from 'zod'; +import { emailSender } from '../../../../email'; +import { db } from '../../db/db'; +import { tokensTable } from '../../db/schema/tokens'; +import { usersTable } from '../../db/schema/users'; +import { errorResponse } from '../../lib/errors'; +import { i18n } from '../../lib/i18n'; +import { nanoid } from '../../lib/nanoid'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import generalRouteConfig from '../general/routes'; +import { transformDatabaseUser } from '../users/helpers/transform-database-user'; +import { removeSessionCookie, setSessionCookie } from './helpers/cookies'; +import { handleCreateUser } from './helpers/user'; +import { sendVerificationEmail } from './helpers/verify-email'; +import authRoutesConfig from './routes'; + +// Scopes for OAuth providers +const githubScopes = { scopes: ['user:email'] }; +const googleScopes = { scopes: ['profile', 'email'] }; +const microsoftScopes = { scopes: ['profile', 'email'] }; + +const app = new CustomHono(); + +type CheckTokenResponse = z.infer<(typeof generalRouteConfig.checkToken.responses)['200']['content']['application/json']['schema']> | undefined; +type TokenData = Extract['data']; + +// Authentication endpoints +const authRoutes = app + /* + * Check if email exists + */ + .openapi(authRoutesConfig.checkEmail, async (ctx) => { + const { email } = ctx.req.valid('json'); + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + + return ctx.json({ success: !!user }, 200); + }) + /* + * Sign up with email and password + */ + .openapi(authRoutesConfig.signUp, async (ctx) => { + const { email, password, token } = ctx.req.valid('json'); + + let tokenData: TokenData | undefined; + if (token) { + const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path.replace('{token}', token)}`); + + const data = (await response.json()) as CheckTokenResponse; + tokenData = data?.data; + } + + // hash password + const hashedPassword = await new Argon2id().hash(password); + const userId = nanoid(); + + const slug = slugFromEmail(email); + + const isEmailVerified = tokenData?.email === email; + + // create user and send verification email + await handleCreateUser( + ctx, + { + id: userId, + slug, + name: slug, + email: email.toLowerCase(), + language: config.defaultLanguage, + hashedPassword, + }, + { + isEmailVerified, + }, + ); + + return ctx.json({ success: true }, 200); + }) + /* + * Send verification email + */ + .openapi(authRoutesConfig.sendVerificationEmail, async (ctx) => { + const { email } = ctx.req.valid('json'); + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER'); + + // creating email verification token + await db.delete(tokensTable).where(eq(tokensTable.userId, user.id)); + const token = generateId(40); + await db.insert(tokensTable).values({ + id: token, + type: 'EMAIL_VERIFICATION', + userId: user.id, + email, + expiresAt: createDate(new TimeSpan(2, 'h')), + }); + + const emailLanguage = user?.language || config.defaultLanguage; + + // generating email html + const emailHtml = render( + VerificationEmail({ + i18n: i18n.cloneInstance({ + lng: i18n.languages.includes(emailLanguage) ? emailLanguage : config.defaultLanguage, + }), + verificationLink: `${config.frontendUrl}/auth/verify-email/${token}`, + }), + ); + emailSender.send(email, 'Verify email for Cella', emailHtml); + + logEvent('Verification email sent', { user: user.id }); + + return ctx.json({ success: true }, 200); + }) + /* + * Verify email + */ + .openapi(authRoutesConfig.verifyEmail, async (ctx) => { + const { resend } = ctx.req.valid('query'); + const { token: verificationToken } = ctx.req.valid('json'); + + const [token] = await db.select().from(tokensTable).where(eq(tokensTable.id, verificationToken)); + + // If the token is not found or expired + if (!token || !token.userId || !isWithinExpirationDate(token.expiresAt)) { + // If 'resend' is true and the token has an email we will resend the email + if (resend === 'true' && token && token.email) { + sendVerificationEmail(token.email); + + await db.delete(tokensTable).where(eq(tokensTable.id, verificationToken)); + + return ctx.json({ success: true }, 200); + } + + // t('common:error.invalid_token') + return errorResponse(ctx, 400, 'invalid_token', 'warn', undefined, { + user: token?.userId || 'na', + type: 'verification', + }); + } + + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, token.userId)); + + // If the user is not found or the email is different from the token email + if (!user || user.email !== token.email) { + // If 'resend' is true and the token has an email we will resend the email + if (resend === 'true' && token && token.email) { + sendVerificationEmail(token.email); + + await db.delete(tokensTable).where(eq(tokensTable.id, verificationToken)); + + return ctx.json({ success: true }, 200); + } + + return errorResponse(ctx, 400, 'invalid_token', 'warn'); + } + + await db.update(usersTable).set({ emailVerified: true }).where(eq(usersTable.id, user.id)); + + // Sign in user + await setSessionCookie(ctx, user.id, 'email_verification'); + + return ctx.json({ success: true }, 200); + }) + /* + * Request reset password email + */ + .openapi(authRoutesConfig.resetPassword, async (ctx) => { + const { email } = ctx.req.valid('json'); + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + + if (!user) { + // t('common:error.invalid_email') + return errorResponse(ctx, 400, 'invalid_email', 'warn'); + } + + // creating password reset token + await db.delete(tokensTable).where(eq(tokensTable.userId, user.id)); + const token = generateId(40); + await db.insert(tokensTable).values({ + id: token, + type: 'PASSWORD_RESET', + userId: user.id, + email, + expiresAt: createDate(new TimeSpan(2, 'h')), + }); + + const emailLanguage = user?.language || config.defaultLanguage; + + // generating email html + const emailHtml = render( + ResetPasswordEmail({ + i18n: i18n.cloneInstance({ + lng: i18n.languages.includes(emailLanguage) ? emailLanguage : config.defaultLanguage, + }), + resetPasswordLink: `${config.frontendUrl}/auth/reset-password/${token}`, + }), + ); + + emailSender.send(email, 'Reset Cella password', emailHtml); + + logEvent('Reset password link sent', { user: user.id }); + + return ctx.json({ success: true }, 200); + }) + /* + * Reset password with token + */ + .openapi(authRoutesConfig.resetPasswordCallback, async (ctx) => { + const { password } = ctx.req.valid('json'); + const verificationToken = ctx.req.valid('param').token; + + const [token] = await db.select().from(tokensTable).where(eq(tokensTable.id, verificationToken)); + await db.delete(tokensTable).where(eq(tokensTable.id, verificationToken)); + + // If the token is not found or expired + if (!token || !token.userId || !isWithinExpirationDate(token.expiresAt)) { + return errorResponse(ctx, 400, 'invalid_token', 'warn'); + } + + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, token.userId)); + + // If the user is not found or the email is different from the token email + if (!user || user.email !== token.email) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { userId: token.userId }); + + await auth.invalidateUserSessions(user.id); + + // hash password + const hashedPassword = await new Argon2id().hash(password); + + // update user password and set email verified + await db.update(usersTable).set({ hashedPassword, emailVerified: true }).where(eq(usersTable.id, user.id)); + + // Sign in user + await setSessionCookie(ctx, user.id, 'password_reset'); + + return ctx.json({ success: true }, 200); + }) + /* + * Sign in with email and password + */ + .openapi(authRoutesConfig.signIn, async (ctx) => { + const { email, password, token } = ctx.req.valid('json'); + + let tokenData: TokenData | undefined; + if (token) { + const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path.replace('{token}', token)}`); + + const data = (await response.json()) as CheckTokenResponse; + tokenData = data?.data; + } + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + + // If the user is not found or signed up with oauth + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER'); + if (!user.hashedPassword) return errorResponse(ctx, 404, 'no_password_found', 'warn'); + + const validPassword = await new Argon2id().verify(user.hashedPassword, password); + + if (!validPassword) return errorResponse(ctx, 400, 'invalid_password', 'warn'); + + const isEmailVerified = user.emailVerified || tokenData?.email === user.email; + + // send verify email first + if (!isEmailVerified) { + sendVerificationEmail(email); + + // TODO return ctx.redirect(`${config.frontendUrl}/auth/verify-email`); + // return ctx.json({}, 302, { + // Location: `${config.frontendUrl}/auth/verify-email`, + // }); + } else { + await setSessionCookie(ctx, user.id, 'password'); + } + + return ctx.json({ success: true, data: transformDatabaseUser(user) }, 200); + }) + /* + * Sign out + */ + .openapi(authRoutesConfig.signOut, async (ctx) => { + const cookieHeader = ctx.req.raw.headers.get('Cookie'); + const sessionId = auth.readSessionCookie(cookieHeader ?? ''); + + if (!sessionId) { + removeSessionCookie(ctx); + return errorResponse(ctx, 401, 'unauthorized', 'warn'); + } + + const { session } = await auth.validateSession(sessionId); + + if (session) { + await auth.invalidateSession(session.id); + } + + removeSessionCookie(ctx); + logEvent('User signed out', { user: session?.userId || 'na' }); + + return ctx.json({ success: true }, 200); + }) + /* + * Github authentication + */ + .openapi(authRoutesConfig.githubSignIn, async (ctx) => { + const { redirect } = ctx.req.valid('query'); + + const state = generateState(); + const url = await githubAuth.createAuthorizationURL(state, githubScopes); + + createSession(ctx, 'github', state, '', redirect); + + return ctx.redirect(url.toString(), 302); + }) + /* + * Google authentication + */ + .openapi(authRoutesConfig.googleSignIn, async (ctx) => { + const { redirect } = ctx.req.valid('query'); + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await googleAuth.createAuthorizationURL(state, codeVerifier, googleScopes); + + createSession(ctx, 'google', state, codeVerifier, redirect); + + return ctx.redirect(url.toString(), 302); + }) + /* + * Microsoft authentication + */ + .openapi(authRoutesConfig.microsoftSignIn, async (ctx) => { + const { redirect } = ctx.req.valid('query'); + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await microsoftAuth.createAuthorizationURL(state, codeVerifier, microsoftScopes); + + createSession(ctx, 'microsoft', state, codeVerifier, redirect); + + return ctx.redirect(url.toString(), 302); + }) + /* + * Github authentication callback + */ + .openapi(authRoutesConfig.githubSignInCallback, async (ctx) => { + const { code, state } = ctx.req.valid('query'); + + const stateCookie = getCookie(ctx, 'oauth_state'); + + // verify state + if (!state || !stateCookie || !code || stateCookie !== state) { + // t('common:error.invalid_state.text') + return errorResponse(ctx, 400, 'invalid_state', 'warn', undefined, { strategy: 'github' }); + } + + const redirectExistingUserUrl = getRedirectUrl(ctx); + + try { + const { accessToken } = await githubAuth.validateAuthorizationCode(code); + + // Get user info from github + const githubUserResponse = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const githubUser: { + avatar_url: string; + bio: string | null; + blog: string | null; + company: string | null; + created_at: string; + email: string | null; + events_url: string; + followers: number; + followers_url: string; + following: number; + following_url: string; + gists_url: string; + gravatar_id: string | null; + hireable: boolean | null; + html_url: string; + id: number; + location: string | null; + login: string; + name: string | null; + node_id: string; + organizations_url: string; + public_gists: number; + public_repos: number; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + updated_at: string; + url: string; + twitter_username?: string | null; + } = await githubUserResponse.json(); + + // Check if oauth account already exists + const [existingOauthAccount] = await findOauthAccount('GITHUB', String(githubUser.id)); + if (existingOauthAccount) { + await setSessionCookie(ctx, existingOauthAccount.userId, 'github'); + + return ctx.redirect(redirectExistingUserUrl, 302); + } + + // Get user emails from github + const githubUserEmailsResponse = await fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + const githubUserEmails: { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; + }[] = await githubUserEmailsResponse.json(); + + const primaryEmail = githubUserEmails.find((email) => email.primary); + + if (!primaryEmail) { + // t('common:error.no_email_found.text') + return errorResponse(ctx, 400, 'no_email_found', 'warn'); + } + + const slug = slugify(githubUser.login, { lower: true }); + const { firstName, lastName } = splitFullName(githubUser.name || slug); + + // Check if user has an invite token + const inviteToken = getCookie(ctx, 'oauth_invite_token'); + + deleteCookie(ctx, 'oauth_invite_token'); + + const githubUserEmail = primaryEmail.email.toLowerCase(); + let userEmail = githubUserEmail; + + if (inviteToken) { + const [token] = await db.select().from(tokensTable).where(eq(tokensTable.id, inviteToken)); + + // If token is invalid or expired + if (!token || !token.email || !isWithinExpirationDate(token.expiresAt)) { + return errorResponse(ctx, 400, 'invalid_token', 'warn', undefined, { + strategy: 'github', + type: 'invitation', + }); + } + + userEmail = token.email; + } + + // Check if user already exists + const [existingUser] = await findUserByEmail(userEmail); + if (existingUser) { + return await handleExistingUser(ctx, existingUser, 'GITHUB', { + providerUser: { + id: String(githubUser.id), + email: githubUserEmail, + bio: githubUser.bio, + thumbnailUrl: githubUser.avatar_url, + firstName, + lastName, + }, + redirectUrl: redirectExistingUserUrl, + isEmailVerified: existingUser.emailVerified || !!inviteToken || primaryEmail.verified, + }); + } + + const userId = nanoid(); + const redirectNewUserUrl = getRedirectUrl(ctx, true); + // Create new user and oauth account + return await handleCreateUser( + ctx, + { + id: userId, + slug: slugify(githubUser.login, { lower: true }), + email: primaryEmail.email.toLowerCase(), + name: githubUser.name || githubUser.login, + thumbnailUrl: githubUser.avatar_url, + bio: githubUser.bio, + emailVerified: primaryEmail.verified, + language: config.defaultLanguage, + firstName, + lastName, + }, + { + provider: { + id: 'GITHUB', + userId: String(githubUser.id), + }, + isEmailVerified: primaryEmail.verified, + redirectUrl: redirectNewUserUrl, + }, + ); + } catch (error) { + // Handle invalid credentials + if (error instanceof OAuth2RequestError) { + // t('common:error.invalid_credentials.text') + return errorResponse(ctx, 400, 'invalid_credentials', 'warn', undefined, { strategy: 'github' }); + } + + logEvent('Error signing in with GitHub', { strategy: 'github', errorMessage: (error as Error).message }, 'error'); + + throw error; + } + }) + /* + * Google authentication callback + */ + .openapi(authRoutesConfig.googleSignInCallback, async (ctx) => { + const { state, code } = ctx.req.valid('query'); + + const storedState = getCookie(ctx, 'oauth_state'); + const storedCodeVerifier = getCookie(ctx, 'oauth_code_verifier'); + + // verify state + if (!code || !storedState || !storedCodeVerifier || state !== storedState) { + return errorResponse(ctx, 400, 'invalid_state', 'warn', undefined, { strategy: 'google' }); + } + + const redirectExistingUserUrl = getRedirectUrl(ctx); + + try { + const { accessToken } = await googleAuth.validateAuthorizationCode(code, storedCodeVerifier); + + // Get user info from google + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const user: { + sub: string; + name: string; + given_name: string; + family_name: string; + picture: string; + email: string; + email_verified: boolean; + locale: string; + } = await response.json(); + + // Check if oauth account already exists + const [existingOauthAccount] = await findOauthAccount('GOOGLE', user.sub); + if (existingOauthAccount) { + await setSessionCookie(ctx, existingOauthAccount.userId, 'google'); + + return ctx.redirect(redirectExistingUserUrl, 302); + } + + // Check if user already exists + const [existingUser] = await findUserByEmail(user.email.toLowerCase()); + if (existingUser) { + return await handleExistingUser(ctx, existingUser, 'GOOGLE', { + providerUser: { + id: user.sub, + email: user.email, + thumbnailUrl: user.picture, + firstName: user.given_name, + lastName: user.family_name, + }, + redirectUrl: redirectExistingUserUrl, + // TODO: invite token + isEmailVerified: existingUser.emailVerified || user.email_verified, + }); + } + + const userId = nanoid(); + const redirectNewUserUrl = getRedirectUrl(ctx, true); + // Create new user and oauth account + return await handleCreateUser( + ctx, + { + id: userId, + slug: slugFromEmail(user.email), + email: user.email.toLowerCase(), + name: user.given_name, + language: config.defaultLanguage, + thumbnailUrl: user.picture, + firstName: user.given_name, + lastName: user.family_name, + }, + { + provider: { + id: 'GOOGLE', + userId: user.sub, + }, + isEmailVerified: user.email_verified, + redirectUrl: redirectNewUserUrl, + }, + ); + } catch (error) { + // Handle invalid credentials + if (error instanceof OAuth2RequestError) { + return errorResponse(ctx, 400, 'invalid_credentials', 'warn', undefined, { strategy: 'google' }); + } + + const errorMessage = (error as Error).message; + logEvent('Error signing in with Google', { strategy: 'google', errorMessage }, 'error'); + + throw error; + } + }) + /* + * Microsoft authentication callback + */ + .openapi(authRoutesConfig.microsoftSignInCallback, async (ctx) => { + const { state, code } = ctx.req.valid('query'); + + const storedState = getCookie(ctx, 'oauth_state'); + const storedCodeVerifier = getCookie(ctx, 'oauth_code_verifier'); + + // verify state + if (!code || !storedState || !storedCodeVerifier || state !== storedState) { + return errorResponse(ctx, 400, 'invalid_state', 'warn', undefined, { strategy: 'microsoft' }); + } + + const redirectExistingUserUrl = getRedirectUrl(ctx); + + try { + const { accessToken } = await microsoftAuth.validateAuthorizationCode(code, storedCodeVerifier); + + // Get user info from microsoft + const response = await fetch('https://graph.microsoft.com/oidc/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const user: { + sub: string; + name: string; + given_name: string; + family_name: string; + picture: string; + email: string | undefined; + } = await response.json(); + + // Check if oauth account already exists + const [existingOauthAccount] = await findOauthAccount('MICROSOFT', user.sub); + if (existingOauthAccount) { + await setSessionCookie(ctx, existingOauthAccount.userId, 'microsoft'); + + return ctx.redirect(redirectExistingUserUrl, 302); + } + + if (!user.email) { + return errorResponse(ctx, 400, 'no_email_found', 'warn', undefined); + } + + // Check if user already exists + const [existingUser] = await findUserByEmail(user.email.toLowerCase()); + if (existingUser) { + return await handleExistingUser(ctx, existingUser, 'MICROSOFT', { + providerUser: { + id: user.sub, + email: user.email, + thumbnailUrl: user.picture, + firstName: user.given_name, + lastName: user.family_name, + }, + redirectUrl: redirectExistingUserUrl, + // TODO: invite token and email verification + isEmailVerified: existingUser.emailVerified, + }); + } + + const userId = nanoid(); + const redirectNewUserUrl = getRedirectUrl(ctx, true); + // Create new user and oauth account + return await handleCreateUser( + ctx, + { + id: userId, + slug: slugFromEmail(user.email), + language: config.defaultLanguage, + email: user.email.toLowerCase(), + name: user.given_name, + thumbnailUrl: user.picture, + firstName: user.given_name, + lastName: user.family_name, + }, + { + provider: { + id: 'MICROSOFT', + userId: user.sub, + }, + isEmailVerified: false, + redirectUrl: redirectNewUserUrl, + }, + ); + } catch (error) { + // Handle invalid credentials + if (error instanceof OAuth2RequestError) { + return errorResponse(ctx, 400, 'invalid_credentials', 'warn', undefined, { strategy: 'microsoft' }); + } + + const errorMessage = (error as Error).message; + logEvent('Error signing in with Microsoft', { strategy: 'microsoft', errorMessage }, 'error'); + + throw error; + } + }); + +export default authRoutes; diff --git a/backend/src/modules/auth/routes.ts b/backend/src/modules/auth/routes.ts new file mode 100644 index 000000000..4e4d581f8 --- /dev/null +++ b/backend/src/modules/auth/routes.ts @@ -0,0 +1,409 @@ +import { z } from '@hono/zod-openapi'; + +import { errorResponses, successWithDataSchema, successWithoutDataSchema } from '../../lib/common-responses'; +import { cookieSchema, passwordSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isPublicAccess } from '../../middlewares/guard'; +import { authRateLimiter } from '../../middlewares/rate-limiter'; +import { signInRateLimiter } from '../../middlewares/rate-limiter/sign-in'; +import { userSchema } from '../users/schema'; +import { emailBodySchema, authBodySchema } from './schema'; + +class AuthRoutesConfig { + public checkEmail = createRouteConfig({ + method: 'post', + path: '/check-email', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Check if email exists', + description: 'Check if email address exists in the database.', + security: [], + request: { + body: { + content: { + 'application/json': { + schema: emailBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Email exists', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public signUp = createRouteConfig({ + method: 'post', + path: '/sign-up', + guard: isPublicAccess, + tags: ['auth'], + summary: 'Sign up with password', + description: 'Sign up with email and password. User will receive a verification email.', + middleware: [authRateLimiter], + security: [], + request: { + body: { + content: { + 'application/json': { + schema: authBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User signed up', + headers: z.object({ + 'Set-Cookie': cookieSchema, + }), + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public sendVerificationEmail = createRouteConfig({ + method: 'post', + path: '/send-verification-email', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Resend verification email', + description: 'Resend verification email to user based on email address.', + security: [], + request: { + body: { + content: { + 'application/json': { + schema: emailBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Verification email sent', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public verifyEmail = createRouteConfig({ + method: 'post', + path: '/verify-email', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Verify email by token', + description: 'Verify email address by token from the verification email. Receive a user session when successful.', + security: [], + request: { + query: z.object({ + resend: z.string().optional(), + }), + body: { + content: { + 'application/json': { + schema: z.object({ + token: z.string(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: 'Verified & session given', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public resetPassword = createRouteConfig({ + method: 'post', + path: '/reset-password', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Request reset password', + description: 'An email will be sent with a link to reset password.', + security: [], + request: { + body: { + content: { + 'application/json': { + schema: emailBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Password reset email sent', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public resetPasswordCallback = createRouteConfig({ + method: 'post', + path: '/reset-password/{token}', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Submit password by token', + description: 'Submit new password and directly receive a user session.', + security: [], + request: { + params: z.object({ token: z.string() }), + body: { + content: { + 'application/json': { + schema: z.object({ password: passwordSchema }), + }, + }, + }, + }, + responses: { + 200: { + description: 'Password reset successfully', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public signIn = createRouteConfig({ + method: 'post', + path: '/sign-in', + guard: isPublicAccess, + middleware: [signInRateLimiter()], + tags: ['auth'], + summary: 'Sign in with password', + description: 'Sign in with email and password.', + security: [], + request: { + body: { + content: { + 'application/json': { + schema: authBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User signed in', + headers: z.object({ + 'Set-Cookie': cookieSchema, + }), + content: { + 'application/json': { + schema: successWithDataSchema(userSchema), + }, + }, + }, + 302: { + description: 'Email address not verified', + headers: z.object({ Location: z.string() }), + }, + ...errorResponses, + }, + }); + + public githubSignIn = createRouteConfig({ + method: 'get', + path: '/github', + guard: isPublicAccess, + tags: ['auth'], + summary: 'Authenticate with GitHub', + description: 'Authenticate with Github to sign in or sign up.', + security: [], + request: { + query: z.object({ redirect: z.string().optional() }), + }, + responses: { + 302: { + description: 'Redirect to GitHub', + headers: z.object({ Location: z.string() }), + }, + ...errorResponses, + }, + }); + + public githubSignInCallback = createRouteConfig({ + method: 'get', + path: '/github/callback', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Callback for GitHub', + description: 'Callback to receive authorization and basic user data.', + security: [], + request: { + query: z.object({ + code: z.string(), + state: z.string(), + }), + }, + responses: { + 302: { + description: 'Redirect to frontend', + headers: z.object({ + Location: z.string(), + }), + }, + ...errorResponses, + }, + }); + + public googleSignIn = createRouteConfig({ + method: 'get', + path: '/google', + guard: isPublicAccess, + tags: ['auth'], + summary: 'Authenticate with Google', + description: 'Authenticate with Google to sign in or sign up.', + security: [], + request: { + query: z.object({ redirect: z.string().optional() }), + }, + responses: { + 302: { + description: 'Redirect to Google', + headers: z.object({ Location: z.string() }), + }, + ...errorResponses, + }, + }); + + public googleSignInCallback = createRouteConfig({ + method: 'get', + path: '/google/callback', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Callback for Google', + description: 'Callback to receive authorization and basic user data.', + security: [], + request: { + query: z.object({ + code: z.string(), + state: z.string(), + }), + }, + responses: { + 302: { + description: 'Redirect to frontend', + headers: z.object({ + Location: z.string(), + }), + }, + ...errorResponses, + }, + }); + + public microsoftSignIn = createRouteConfig({ + method: 'get', + path: '/microsoft', + guard: isPublicAccess, + tags: ['auth'], + summary: 'Authenticate with Microsoft', + description: 'Authenticate with Microsoft to sign in or sign up.', + security: [], + request: { + query: z.object({ + redirect: z.string().optional(), + }), + }, + responses: { + 302: { + description: 'Redirect to Microsoft', + headers: z.object({ + Location: z.string(), + }), + }, + ...errorResponses, + }, + }); + + public microsoftSignInCallback = createRouteConfig({ + method: 'get', + path: '/microsoft/callback', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['auth'], + summary: 'Callback for Microsoft', + description: 'Callback to receive authorization and basic user data.', + security: [], + request: { + query: z.object({ + code: z.string(), + state: z.string(), + }), + }, + responses: { + 302: { + description: 'Redirect to frontend', + headers: z.object({ + Location: z.string(), + }), + }, + ...errorResponses, + }, + }); + + public signOut = createRouteConfig({ + method: 'get', + path: '/sign-out', + guard: isPublicAccess, + tags: ['auth'], + summary: 'Sign out', + description: 'Sign out yourself and clear session.', + responses: { + 200: { + description: 'User signed out', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); +} + +export default new AuthRoutesConfig(); diff --git a/backend/src/modules/auth/schema.ts b/backend/src/modules/auth/schema.ts new file mode 100644 index 000000000..4482ab13b --- /dev/null +++ b/backend/src/modules/auth/schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { passwordSchema } from '../../lib/common-schemas'; +import { userSchema } from '../users/schema'; + +export const authBodySchema = z.object({ + email: userSchema.shape.email, + password: passwordSchema, + token: z.string().optional(), +}); + +export const emailBodySchema = z.object({ + email: userSchema.shape.email, +}); diff --git a/backend/src/modules/general/helpers/check-slug.ts b/backend/src/modules/general/helpers/check-slug.ts new file mode 100644 index 000000000..1d0839060 --- /dev/null +++ b/backend/src/modules/general/helpers/check-slug.ts @@ -0,0 +1,15 @@ +import { config } from 'config'; +import { resolveEntity } from '../../../lib/entity'; + +// Check if a slug is available in any of the entities +export const checkSlugAvailable = async (slug: string) => { + const entities = config.entityTypes; + + const promises = entities.map((entity) => resolveEntity(entity, slug)); + const results = await Promise.all(promises); + + // Check if any result is found, if so, slug is not available + const isAvailable = results.every((result) => !result); + + return isAvailable; +}; diff --git a/backend/src/modules/general/index.ts b/backend/src/modules/general/index.ts new file mode 100644 index 000000000..ff1a3281c --- /dev/null +++ b/backend/src/modules/general/index.ts @@ -0,0 +1,472 @@ +import { type SQL, and, count, eq, ilike, inArray, or, sql } from 'drizzle-orm'; +import { emailSender } from '../../../../email'; +import { InviteSystemEmail } from '../../../../email/emails/system-invite'; + +import { render } from '@react-email/render'; +import { config } from 'config'; +import { env } from 'env'; +import { type SSEStreamingApi, streamSSE } from 'hono/streaming'; +import jwt from 'jsonwebtoken'; +import { type User, generateId } from 'lucia'; +import { TimeSpan, createDate, isWithinExpirationDate } from 'oslo'; + +import { db } from '../../db/db'; + +import { EventName, Paddle } from '@paddle/paddle-node-sdk'; +import { labelsTable } from '../../db/schema-electric/labels'; +import { tasksTable } from '../../db/schema-electric/tasks'; +import { type MembershipModel, membershipsTable } from '../../db/schema/memberships'; +import { organizationsTable } from '../../db/schema/organizations'; +import { projectsTable } from '../../db/schema/projects'; +import { type TokenModel, tokensTable } from '../../db/schema/tokens'; +import { usersTable } from '../../db/schema/users'; +import { workspacesTable } from '../../db/schema/workspaces'; +import { entityTables, resolveEntity } from '../../lib/entity'; +import { errorResponse } from '../../lib/errors'; +import { getOrderColumn } from '../../lib/order-column'; +import { isAuthenticated } from '../../middlewares/guard'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { toMembershipInfo } from '../memberships/helpers/to-membership-info'; +import { checkSlugAvailable } from './helpers/check-slug'; +import generalRouteConfig from './routes'; +import type { Suggestion } from './schema'; +import { insertMembership } from '../memberships/helpers/insert-membership'; + +const paddle = new Paddle(env.PADDLE_API_KEY || ''); + +const app = new CustomHono(); + +export const streams = new Map(); + +// General endpoints +const generalRoutes = app + /* + * Get public counts + */ + .openapi(generalRouteConfig.getPublicCounts, async (ctx) => { + const [users, organizations, workspaces, projects, tasks, labels] = await Promise.all([ + db.select({ total: sql`count(*)`.mapWith(Number) }).from(usersTable), + db.select({ total: sql`count(*)`.mapWith(Number) }).from(organizationsTable), + db.select({ total: sql`count(*)`.mapWith(Number) }).from(workspacesTable), + db.select({ total: sql`count(*)`.mapWith(Number) }).from(projectsTable), + db.select({ total: sql`count(*)`.mapWith(Number) }).from(tasksTable), + db.select({ total: sql`count(*)`.mapWith(Number) }).from(labelsTable), + ]); + + const data = { + users: users[0].total, + organizations: organizations[0].total, + workspaces: workspaces[0].total, + projects: projects[0].total, + tasks: tasks[0].total, + labels: labels[0].total, + }; + + return ctx.json({ success: true, data }, 200); + }) + /* + * Get upload token + */ + .openapi(generalRouteConfig.getUploadToken, async (ctx) => { + const isPublic = ctx.req.query('public'); + const user = ctx.get('user'); + // TODO: validate query param organization? + const organizationId = ctx.req.query('organizationId'); + + const sub = organizationId ? `${organizationId}/${user.id}` : user.id; + + const token = jwt.sign( + { + sub: sub, + public: isPublic === 'true', + imado: !!env.AWS_S3_UPLOAD_ACCESS_KEY_ID, + }, + env.TUS_UPLOAD_API_SECRET, + ); + + return ctx.json({ success: true, data: token }, 200); + }) + /* + * Check if slug is available + */ + .openapi(generalRouteConfig.checkSlug, async (ctx) => { + const { slug } = ctx.req.valid('json'); + + const slugAvailable = await checkSlugAvailable(slug); + + return ctx.json({ success: slugAvailable }, 200); + }) + /* + * Check token (token validation) + */ + .openapi(generalRouteConfig.checkToken, async (ctx) => { + const { token } = ctx.req.valid('json'); + + // Check if token exists + const [tokenRecord] = await db + .select() + .from(tokensTable) + .where(and(eq(tokensTable.id, token))); + + // if (!tokenRecord?.email) return errorResponse(ctx, 404, 'not_found', 'warn', 'token'); + + // const [user] = await db.select().from(usersTable).where(eq(usersTable.email, tokenRecord.email)); + // if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user'); + + // For reset token: check if token has valid user + // if (tokenRecord.type === 'PASSWORD_RESET') { + // const [user] = await db.select().from(usersTable).where(eq(usersTable.email, tokenRecord.email)); + // if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user'); + // } + + // For system invitation token: check if user email is not already in the system + // if (tokenRecord.type === 'SYSTEM_INVITATION') { + // const [user] = await db.select().from(usersTable).where(eq(usersTable.email, tokenRecord.email)); + // if (user) return errorResponse(ctx, 409, 'email_exists', 'error'); + // } + + const data = { + type: tokenRecord.type, + email: tokenRecord.email || '', + organizationName: '', + organizationSlug: '', + }; + + if (tokenRecord.type === 'ORGANIZATION_INVITATION' && tokenRecord.organizationId) { + const [organization] = await db.select().from(organizationsTable).where(eq(organizationsTable.id, tokenRecord.organizationId)); + data.organizationName = organization.name; + data.organizationSlug = organization.slug; + } + + return ctx.json({ success: true, data }, 200); + }) + /* + * Invite users to the system + */ + .openapi(generalRouteConfig.createInvite, async (ctx) => { + const { emails, role } = ctx.req.valid('json'); + const user = ctx.get('user'); + + for (const email of emails) { + const [targetUser] = (await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase()))) as (User | undefined)[]; + + const token = generateId(40); + await db.insert(tokensTable).values({ + id: token, + type: 'SYSTEM_INVITATION', + userId: targetUser?.id, + email: email.toLowerCase(), + role: (role as TokenModel['role']) || 'USER', + expiresAt: createDate(new TimeSpan(7, 'd')), + }); + + const emailHtml = render( + InviteSystemEmail({ + user, + targetUser, + token, + }), + ); + logEvent('User invited on system level'); + + emailSender.send(config.senderIsReceiver ? user.email : email.toLowerCase(), 'Invitation to Cella', emailHtml, user.email).catch((error) => { + logEvent('Error sending email', { error: (error as Error).message }, 'error'); + }); + } + + return ctx.json({ success: true }, 200); + }) + /* + * Accept invite token + */ + .openapi(generalRouteConfig.acceptInvite, async (ctx) => { + const verificationToken = ctx.req.valid('param').token; + + const [token] = await db + .select() + .from(tokensTable) + .where(and(eq(tokensTable.id, verificationToken))); + + // Delete token + await db.delete(tokensTable).where(eq(tokensTable.id, verificationToken)); + + if (!token || !token.email || !token.role || !isWithinExpirationDate(token.expiresAt)) { + return errorResponse(ctx, 400, 'invalid_token_or_expired', 'warn'); + } + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, token.email)); + + if (!user) { + return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { email: token.email }); + } + + // If it is a system invitation, update user role + if (token.type === 'SYSTEM_INVITATION') { + if (token.role === 'ADMIN') { + await db.update(usersTable).set({ role: 'ADMIN' }).where(eq(usersTable.id, user.id)); + } + + return ctx.json({ success: true }, 200); + } + + if (token.type === 'ORGANIZATION_INVITATION') { + if (!token.organizationId) { + return errorResponse(ctx, 400, 'invalid_token', 'warn'); + } + + const [organization] = await db + .select() + .from(organizationsTable) + .where(and(eq(organizationsTable.id, token.organizationId))); + + if (!organization) { + return errorResponse(ctx, 404, 'not_found', 'warn', 'ORGANIZATION', { organization: token.organizationId }); + } + + const [existingMembership] = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); + + if (existingMembership) { + if (existingMembership.role !== token.role) { + await db + .update(membershipsTable) + .set({ role: token.role as MembershipModel['role'] }) + .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); + } + + return ctx.json({ success: true }, 200); + } + + // Insert membership + const role = token.role as MembershipModel['role']; + await insertMembership({ user, role, entity: organization }); + } + + return ctx.json({ success: true }, 200); + }) + /* + * Paddle webhook + */ + .openapi(generalRouteConfig.paddleWebhook, async (ctx) => { + const signature = ctx.req.header('paddle-signature'); + const rawRequestBody = String(ctx.req.raw.body); + + try { + if (signature && rawRequestBody) { + const eventData = paddle.webhooks.unmarshal(rawRequestBody, env.PADDLE_WEBHOOK_KEY || '', signature); + switch (eventData?.eventType) { + case EventName.SubscriptionCreated: + logEvent(`Subscription ${eventData.data.id} was created`, { + ecent: JSON.stringify(eventData), + }); + break; + default: + logEvent('Unhandled paddle event', { + event: JSON.stringify(eventData), + }); + } + } + } catch (error) { + logEvent('Error handling paddle webhook', { error: (error as Error).message }, 'error'); + } + + return ctx.json({ success: true }, 200); + }) + /* + * Get suggestions + */ + .openapi(generalRouteConfig.getSuggestionsConfig, async (ctx) => { + const { q, type } = ctx.req.valid('query'); + const user = ctx.get('user'); + + // Retrieve user memberships to filter suggestions by relevant organization, ADMIN users see everything + const memberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); + + // Retrieve organizationIds for non-admin users and check if the user has at least one organization membership + let organizationIds: string[] = []; + + if (user.role !== 'ADMIN') { + organizationIds = memberships.filter((el) => el.type === 'ORGANIZATION').map((el) => String(el.organizationId)); + if (!organizationIds.length) return errorResponse(ctx, 403, 'forbidden', 'warn', undefined, { user: user.id }); + } + + // Provide suggestions for all entities or narrow them down to a specific type if specified + const entityTypes = type ? [type] : config.entityTypes; + + // Array to hold queries for concurrent execution + const queries = []; + + // Build queries + for (const entityType of entityTypes) { + const table = entityTables.get(entityType); + if (!table) continue; + + // Build selection + const select = { + id: table.id, + slug: table.slug, + name: table.name, + entity: table.entity, + ...('email' in table && { email: table.email }), + ...('organizationId' in table && { organizationId: table.organizationId }), + ...('thumbnailUrl' in table && { thumbnailUrl: table.thumbnailUrl }), + }; + + // Build search filters + const $or = [ilike(table.name, `%${q}%`)]; + if ('email' in table) $or.push(ilike(table.email, `%${q}%`)); + + // Build organization filters + const $and = []; + + if (organizationIds.length) { + if ('organizationId' in table) { + $and.push(inArray(table.organizationId, organizationIds)); + } else if (entityType === 'ORGANIZATION') { + $and.push(inArray(table.id, organizationIds)); + } else if (entityType === 'USER') { + // Filter users based on their memberships in specified organizations + const userMemberships = await db + .select({ userId: membershipsTable.userId }) + .from(membershipsTable) + .where(and(inArray(membershipsTable.organizationId, organizationIds), eq(membershipsTable.type, 'ORGANIZATION'))); + + if (!userMemberships.length) continue; + $and.push( + inArray( + table.id, + userMemberships.map((el) => String(el.userId)), + ), + ); + } + } + + $and.push($or.length > 1 ? or(...$or) : $or[0]); + const $where = $and.length > 1 ? and(...$and) : $and[0]; + + // Build query + queries.push(db.select(select).from(table).where($where).limit(10)); + } + + const results = await Promise.all(queries); + const items = []; + + // @TODO: Tmp Typescript type solution + for (const entities of results as unknown as Array) items.push(...entities.map((e) => e)); + + return ctx.json({ success: true, data: { items, total: items.length } }, 200); + }) + /* + * Get members by entity id and type + */ + .openapi(generalRouteConfig.getMembers, async (ctx) => { + const { idOrSlug, entityType, q, sort, order, offset, limit, role } = ctx.req.valid('query'); + const entity = await resolveEntity(entityType, idOrSlug); + + // TODO use filter query helper to avoid code duplication. Also, this specific filter is missing name search? + const filter: SQL | undefined = q ? ilike(usersTable.email, `%${q}%`) : undefined; + + const usersQuery = db.select().from(usersTable).where(filter).as('users'); + + // TODO refactor this to use agnostic entity mapping to use 'entityType'+Id in a clean way + const membersFilters = [ + eq(entityType === 'ORGANIZATION' ? membershipsTable.organizationId : membershipsTable.projectId, entity.id), + eq(membershipsTable.type, entityType), + ]; + + if (role) { + membersFilters.push(eq(membershipsTable.role, role.toUpperCase() as MembershipModel['role'])); + } + + const memberships = db + .select() + .from(membershipsTable) + .where(and(...membersFilters)) + .as('memberships'); + + // TODO: use count helper? + const membershipCount = db + .select({ + userId: membershipsTable.userId, + memberships: count().as('memberships'), + }) + .from(membershipsTable) + .groupBy(membershipsTable.userId) + .as('membership_count'); + + const orderColumn = getOrderColumn( + { + id: usersTable.id, + name: usersTable.name, + email: usersTable.email, + createdAt: usersTable.createdAt, + lastSeenAt: usersTable.lastSeenAt, + role: memberships.role, + }, + sort, + usersTable.id, + order, + ); + + const membersQuery = db + .select({ + user: usersTable, + membership: membershipsTable, + counts: { + memberships: membershipCount.memberships, + }, + }) + .from(usersQuery) + .innerJoin(memberships, eq(usersTable.id, memberships.userId)) + .leftJoin(membershipCount, eq(usersTable.id, membershipCount.userId)) + .orderBy(orderColumn); + + const [{ total }] = await db.select({ total: count() }).from(membersQuery.as('memberships')); + + const result = await membersQuery.limit(Number(limit)).offset(Number(offset)); + + const members = await Promise.all( + result.map(async ({ user, membership, counts }) => ({ + ...user, + membership: toMembershipInfo.required(membership), + counts, + })), + ); + + return ctx.json({ success: true, data: { items: members, total } }, 200); + }) + /* + * Get SSE stream + */ + .get('/sse', isAuthenticated, async (ctx) => { + const user = ctx.get('user'); + return streamSSE(ctx, async (stream) => { + streams.set(user.id, stream); + console.info('User connected to SSE', user.id); + await stream.writeSSE({ + event: 'connected', + data: 'connected', + retry: 5000, + }); + + stream.onAbort(async () => { + console.info('User disconnected from SSE', user.id); + streams.delete(user.id); + }); + + // Keep connection alive + while (true) { + await stream.writeSSE({ + event: 'ping', + data: 'pong', + retry: 5000, + }); + await stream.sleep(30000); + } + }); + }); + +export default generalRoutes; diff --git a/backend/src/modules/general/routes.ts b/backend/src/modules/general/routes.ts new file mode 100644 index 000000000..9b002e830 --- /dev/null +++ b/backend/src/modules/general/routes.ts @@ -0,0 +1,275 @@ +import { z } from '@hono/zod-openapi'; +import { errorResponses, successWithDataSchema, successWithPaginationSchema, successWithoutDataSchema } from '../../lib/common-responses'; +import { entityTypeSchema, slugSchema, tokenSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAuthenticated, isPublicAccess, isSystemAdmin } from '../../middlewares/guard'; +import { authRateLimiter, rateLimiter } from '../../middlewares/rate-limiter'; +import { + acceptInviteBodySchema, + membersSchema, + publicCountsSchema, + checkTokenSchema, + membersQuerySchema, + inviteBodySchema, + suggestionsSchema, +} from './schema'; + +class GeneralRoutesConfig { + public getPublicCounts = createRouteConfig({ + method: 'get', + path: '/public/counts', + guard: isPublicAccess, + tags: ['general'], + summary: 'Get public counts', + responses: { + 200: { + description: 'Public counts', + content: { + 'application/json': { + schema: successWithDataSchema(publicCountsSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getUploadToken = createRouteConfig({ + method: 'get', + path: '/upload-token', + guard: isAuthenticated, + tags: ['general'], + summary: 'Get upload token', + description: + 'This endpoint is used to get an upload token for a user or organization. The token can be used to upload public or private images/files to your S3 bucket using imado.', + request: { + query: z.object({ + public: z.string().optional().default('false'), + organization: z.string().optional(), + width: z.string().optional(), + height: z.string().optional(), + quality: z.string().optional(), + format: z.string().optional(), + }), + }, + responses: { + 200: { + description: 'Upload token with a scope for a user or organization', + content: { + 'application/json': { + schema: successWithDataSchema(z.string()), + }, + }, + }, + ...errorResponses, + }, + }); + + public checkSlug = createRouteConfig({ + method: 'post', + path: '/check-slug', + guard: isAuthenticated, + tags: ['general'], + summary: 'Check if slug is available', + description: 'This endpoint is used to check if a slug is available among ALL contextual entities such as organizations.', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + slug: slugSchema, + }), + }, + }, + }, + }, + responses: { + 200: { + description: 'Slug is available', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public checkToken = createRouteConfig({ + method: 'post', + path: '/check-token', + middleware: [authRateLimiter], + guard: isPublicAccess, + tags: ['general'], + summary: 'Token validation check', + description: + 'This endpoint is used to check if a token is still valid. It is used to provide direct user feedback on the validity of tokens such as reset password and invitation.', + request: { + body: { + content: { + 'application/json': { + schema: tokenSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Token is valid', + content: { + 'application/json': { + schema: successWithDataSchema(checkTokenSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public createInvite = createRouteConfig({ + method: 'post', + path: '/invite', + guard: [isAuthenticated, isSystemAdmin], + middleware: [rateLimiter({ points: 10, duration: 60 * 60, blockDuration: 60 * 10, keyPrefix: 'invite_success' }, 'success')], + tags: ['general'], + summary: 'Invite to system', + description: 'Invite one or more users to system by email address.', + request: { + body: { + content: { + 'application/json': { + schema: inviteBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Invitations are sent', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public acceptInvite = createRouteConfig({ + method: 'post', + path: '/invite/{token}', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['general'], + summary: 'Accept invitation', + description: 'Accept invitation token', + request: { + params: tokenSchema, + body: { + content: { + 'application/json': { + schema: acceptInviteBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Invitation was accepted', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + 302: { + description: 'Redirect to github', + headers: z.object({ + Location: z.string(), + }), + }, + ...errorResponses, + }, + }); + + public paddleWebhook = createRouteConfig({ + method: 'post', + path: '/paddle-webhook', + guard: isPublicAccess, + tags: ['general'], + summary: 'Paddle webhook', + description: 'Paddle webhook for subscription events', + request: { + body: { + content: { + 'application/json': { + schema: z.unknown(), + }, + }, + }, + }, + responses: { + 200: { + description: 'Paddle webhook received', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public getSuggestionsConfig = createRouteConfig({ + method: 'get', + path: '/suggestions', + guard: isAuthenticated, + tags: ['general'], + summary: 'Get list of suggestions', + description: 'Get search suggestions for all entities, such as users and organizations.', + request: { + query: z.object({ + q: z.string().optional(), + type: entityTypeSchema.optional(), + }), + }, + responses: { + 200: { + description: 'Suggestions', + content: { + 'application/json': { + schema: successWithDataSchema(suggestionsSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getMembers = createRouteConfig({ + method: 'get', + path: '/members', + guard: [isAuthenticated], + tags: ['general'], + summary: 'Get list of members', + description: 'Get members of an entity by id or slug. It returns members (users) with their role.', + request: { + query: membersQuerySchema, + }, + responses: { + 200: { + description: 'Members', + content: { + 'application/json': { + schema: successWithPaginationSchema(membersSchema), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new GeneralRoutesConfig(); diff --git a/backend/src/modules/general/schema.ts b/backend/src/modules/general/schema.ts new file mode 100644 index 000000000..2ecf2ab9a --- /dev/null +++ b/backend/src/modules/general/schema.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +import { config } from 'config'; +import { createSelectSchema } from 'drizzle-zod'; +import { tokensTable } from '../../db/schema/tokens'; +import { + contextEntityTypeSchema, + idOrSlugSchema, + idSchema, + imageUrlSchema, + nameSchema, + paginationQuerySchema, + passwordSchema, + slugSchema, +} from '../../lib/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; +import { userSchema } from '../users/schema'; + +export const publicCountsSchema = z.object({ + users: z.number(), + organizations: z.number(), + workspaces: z.number(), + projects: z.number(), + tasks: z.number(), + labels: z.number(), +}); + +export const checkTokenSchema = z.object({ + type: createSelectSchema(tokensTable).shape.type, + email: z.string().email(), + organizationName: z.string().optional(), + organizationSlug: z.string().optional(), +}); + +export const inviteBodySchema = z.object({ + emails: userSchema.shape.email.array().min(1), + role: userSchema.shape.role, +}); + +export const acceptInviteBodySchema = z.object({ + password: passwordSchema.optional(), + oauth: z.enum(config.oauthProviderOptions).optional(), +}); + +export const entitySuggestionSchema = z.object({ + slug: slugSchema, + id: idSchema, + name: nameSchema, + organizationId: idSchema, + email: z.string().optional(), + thumbnailUrl: imageUrlSchema.nullable().optional(), + entity: z.enum(config.entityTypes), +}); + +export type Suggestion = z.infer; + +export const suggestionsSchema = z.object({ + items: z.array(entitySuggestionSchema), + total: z.number(), +}); + +export const membersSchema = z.object({ + ...userSchema.shape, + membership: membershipInfoSchema, +}); + +export const membersQuerySchema = paginationQuerySchema.extend({ + idOrSlug: idOrSlugSchema, + entityType: contextEntityTypeSchema, + sort: z.enum(['id', 'name', 'email', 'role', 'createdAt', 'lastSeenAt']).default('createdAt').optional(), + role: z.enum(config.rolesByType.entityRoles).default('MEMBER').optional(), +}); diff --git a/backend/src/modules/me/helpers/get-sessions.ts b/backend/src/modules/me/helpers/get-sessions.ts new file mode 100644 index 000000000..e1c42a7a7 --- /dev/null +++ b/backend/src/modules/me/helpers/get-sessions.ts @@ -0,0 +1,13 @@ +import type { Context } from 'hono'; +import { auth } from '../../../db/lucia'; + +export const getPreparedSessions = async (userId: string, ctx: Context) => { + const sessions = await auth.getUserSessions(userId); + const currentSessionId = auth.readSessionCookie(ctx.req.raw.headers.get('Cookie') ?? ''); + const preparedSessions = sessions.map((session) => ({ + ...session, + type: 'DESKTOP' as const, + current: session.id === currentSessionId, + })); + return preparedSessions; +}; diff --git a/backend/src/modules/me/index.ts b/backend/src/modules/me/index.ts new file mode 100644 index 000000000..7f00933b4 --- /dev/null +++ b/backend/src/modules/me/index.ts @@ -0,0 +1,248 @@ +import { and, count, desc, eq } from 'drizzle-orm'; + +import { db } from '../../db/db'; +import { auth } from '../../db/lucia'; +import { membershipsTable } from '../../db/schema/memberships'; +import { organizationsTable } from '../../db/schema/organizations'; +import { projectsTable } from '../../db/schema/projects'; +import { usersTable } from '../../db/schema/users'; +import { workspacesTable } from '../../db/schema/workspaces'; +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { removeSessionCookie } from '../auth/helpers/cookies'; +import { checkSlugAvailable } from '../general/helpers/check-slug'; +import { transformDatabaseUserWithCount } from '../users/helpers/transform-database-user'; +import meRoutesConfig from './routes'; + +import { projectsToWorkspacesTable } from '../../db/schema/projects-to-workspaces'; +import { generateElectricJWTToken } from '../../lib/utils'; +import { toMembershipInfo } from '../memberships/helpers/to-membership-info'; +import { getPreparedSessions } from './helpers/get-sessions'; + +const app = new CustomHono(); + +// Me (self) endpoints +const meRoutes = app + /* + * Get current user + */ + .openapi(meRoutesConfig.getSelf, async (ctx) => { + const user = ctx.get('user'); + + const [{ memberships }] = await db + .select({ + memberships: count(), + }) + .from(membershipsTable) + .where(eq(membershipsTable.userId, user.id)); + + // Update last visit date + await db.update(usersTable).set({ lastVisitAt: new Date() }).where(eq(usersTable.id, user.id)); + + // Generate a JWT token for electric + const electricJWTToken = await generateElectricJWTToken({ userId: user.id }); + + return ctx.json( + { + success: true, + data: { + ...transformDatabaseUserWithCount(user, memberships), + sessions: await getPreparedSessions(user.id, ctx), + electricJWTToken, + }, + }, + 200, + ); + }) + /* + * Get current user menu + */ + .openapi(meRoutesConfig.getUserMenu, async (ctx) => { + const user = ctx.get('user'); + + const organizationsWithMemberships = await db + .select({ + organization: organizationsTable, + membership: membershipsTable, + }) + .from(organizationsTable) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, 'ORGANIZATION'))) + .orderBy(desc(organizationsTable.createdAt)) + .innerJoin(membershipsTable, eq(membershipsTable.organizationId, organizationsTable.id)); + + const workspacesWithMemberships = await db + .select({ + workspace: workspacesTable, + membership: membershipsTable, + }) + .from(workspacesTable) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, 'WORKSPACE'))) + .orderBy(desc(workspacesTable.createdAt)) + .innerJoin(membershipsTable, eq(membershipsTable.workspaceId, workspacesTable.id)); + + const projectsWithMemberships = await db + .select({ + project: projectsTable, + membership: membershipsTable, + workspace: projectsToWorkspacesTable, + }) + .from(projectsTable) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, 'PROJECT'))) + .orderBy(desc(projectsTable.createdAt)) + .innerJoin(membershipsTable, eq(membershipsTable.projectId, projectsTable.id)) + .innerJoin(projectsToWorkspacesTable, eq(projectsToWorkspacesTable.projectId, projectsTable.id)); + + const organizations = organizationsWithMemberships.map(({ organization, membership }) => { + return { + slug: organization.slug, + id: organization.id, + createdAt: organization.createdAt, + modifiedAt: organization.modifiedAt, + name: organization.name, + entity: organization.entity, + thumbnailUrl: organization.thumbnailUrl, + membership: toMembershipInfo.required(membership), + }; + }); + + const projects = projectsWithMemberships.map(({ project, membership, workspace }) => { + return { + slug: project.slug, + id: project.id, + createdAt: project.createdAt, + modifiedAt: project.modifiedAt, + name: project.name, + color: project.color, + entity: project.entity, + organizationId: project.organizationId, + membership: toMembershipInfo.required(membership), + parentId: workspace.workspaceId, + }; + }); + + const workspaces = workspacesWithMemberships.map(({ workspace, membership }) => { + return { + slug: workspace.slug, + id: workspace.id, + createdAt: workspace.createdAt, + modifiedAt: workspace.modifiedAt, + name: workspace.name, + thumbnailUrl: workspace.thumbnailUrl, + organizationId: workspace.organizationId, + entity: workspace.entity, + membership: toMembershipInfo.required(membership), + submenu: projects.filter((p) => p.parentId === workspace.id).sort((a, b) => a.membership.order - b.membership.order), + }; + }); + + return ctx.json( + { + success: true, + data: { + organizations: organizations.sort((a, b) => a.membership.order - b.membership.order), + workspaces: workspaces.sort((a, b) => a.membership.order - b.membership.order), + }, + }, + 200, + ); + }) + /* + * Terminate a session + */ + .openapi(meRoutesConfig.deleteSessions, async (ctx) => { + const { ids } = ctx.req.valid('query'); + + const sessionIds = Array.isArray(ids) ? ids : [ids]; + + const cookieHeader = ctx.req.raw.headers.get('Cookie'); + const currentSessionId = auth.readSessionCookie(cookieHeader ?? ''); + + const errors: ErrorType[] = []; + + await Promise.all( + sessionIds.map(async (id) => { + try { + if (id === currentSessionId) { + removeSessionCookie(ctx); + } + await auth.invalidateSession(id); + } catch (error) { + errors.push(createError(ctx, 404, 'not_found', 'warn', undefined, { session: id })); + } + }), + ); + + return ctx.json({ success: true, errors: errors }, 200); + }) + /* + * Update current user (self) + */ + .openapi(meRoutesConfig.updateSelf, async (ctx) => { + const user = ctx.get('user'); + + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { user: 'self' }); + + const { email, bannerUrl, bio, firstName, lastName, language, newsletter, thumbnailUrl, slug } = ctx.req.valid('json'); + + if (slug && slug !== user.slug) { + const slugAvailable = await checkSlugAvailable(slug); + if (!slugAvailable) return errorResponse(ctx, 409, 'slug_exists', 'warn', 'USER', { slug }); + } + + const [updatedUser] = await db + .update(usersTable) + .set({ + email, + bannerUrl, + bio, + firstName, + lastName, + language, + newsletter, + thumbnailUrl, + slug, + name: [firstName, lastName].filter(Boolean).join(' ') || slug, + modifiedAt: new Date(), + modifiedBy: user.id, + }) + .where(eq(usersTable.id, user.id)) + .returning(); + + const [{ memberships }] = await db + .select({ + memberships: count(), + }) + .from(membershipsTable) + .where(eq(membershipsTable.userId, updatedUser.id)); + + logEvent('User updated', { user: updatedUser.id }); + + return ctx.json( + { + success: true, + data: transformDatabaseUserWithCount(updatedUser, memberships), + }, + 200, + ); + }) + /* + * Delete current user (self) + */ + .openapi(meRoutesConfig.deleteSelf, async (ctx) => { + const user = ctx.get('user'); + // Check if user exists + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { user: 'self' }); + + // Delete user + await db.delete(usersTable).where(eq(usersTable.id, user.id)); + + // Invalidate sessions + await auth.invalidateUserSessions(user.id); + removeSessionCookie(ctx); + logEvent('User deleted', { user: user.id }); + + return ctx.json({ success: true }, 200); + }); + +export default meRoutes; diff --git a/backend/src/modules/me/routes.ts b/backend/src/modules/me/routes.ts new file mode 100644 index 000000000..4e250c1c9 --- /dev/null +++ b/backend/src/modules/me/routes.ts @@ -0,0 +1,124 @@ +import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithoutDataSchema } from '../../lib/common-responses'; +import { idsQuerySchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAuthenticated } from '../../middlewares/guard'; +import { userSchema, updateUserBodySchema } from '../users/schema'; +import { meUserSchema, userMenuSchema } from './schema'; + +class MeRoutesConfig { + public getSelf = createRouteConfig({ + method: 'get', + path: '/', + guard: isAuthenticated, + tags: ['me'], + summary: 'Get self', + description: 'Get the current user (self). It includes a `counts` object and a list of `sessions`.', + responses: { + 200: { + description: 'User', + content: { + 'application/json': { + schema: successWithDataSchema(meUserSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public updateSelf = createRouteConfig({ + method: 'put', + path: '/', + guard: isAuthenticated, + tags: ['me'], + summary: 'Update self', + description: 'Update the current user (self).', + request: { + body: { + content: { + 'application/json': { + schema: updateUserBodySchema.omit({ + role: true, + }), + }, + }, + }, + }, + responses: { + 200: { + description: 'User', + content: { + 'application/json': { + schema: successWithDataSchema(userSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteSelf = createRouteConfig({ + method: 'delete', + path: '/', + guard: isAuthenticated, + tags: ['me'], + summary: 'Delete self', + description: 'Delete the current user (self).', + responses: { + 200: { + description: 'User deleted', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public getUserMenu = createRouteConfig({ + method: 'get', + path: '/menu', + guard: isAuthenticated, + tags: ['me'], + summary: 'Get menu of self', + description: + 'Receive menu data with all contextual entities of which the current user is a member. It is in essence a restructured list of `memberships` per entity type, with some entity data in it.', + responses: { + 200: { + description: 'Menu of user', + content: { + 'application/json': { + schema: successWithDataSchema(userMenuSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteSessions = createRouteConfig({ + method: 'delete', + path: '/sessions', + guard: isAuthenticated, + tags: ['me'], + summary: 'Terminate sessions', + description: 'Terminate all sessions of the current user, except for current session.', + request: { + query: idsQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new MeRoutesConfig(); diff --git a/backend/src/modules/me/schema.ts b/backend/src/modules/me/schema.ts new file mode 100644 index 000000000..7371add6a --- /dev/null +++ b/backend/src/modules/me/schema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { config } from 'config'; +import { idSchema, imageUrlSchema, nameSchema, slugSchema } from '../../lib/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; +import { userSchema } from '../users/schema'; + +export const meUserSchema = userSchema.extend({ + electricJWTToken: z.string(), + sessions: z.array(z.object({ id: z.string(), type: z.enum(['MOBILE', 'DESKTOP']), current: z.boolean(), expiresAt: z.string() })), +}); + +const menuItemSchema = z.object({ + slug: slugSchema, + id: idSchema, + createdAt: z.string(), + modifiedAt: z.string().nullable(), + name: nameSchema, + thumbnailUrl: imageUrlSchema.nullish(), + entity: z.enum(config.contextEntityTypes), + membership: membershipInfoSchema, + parentId: z.string().optional(), + organizationId: z.string().optional(), +}); + +const menuItemsSchema = z.array( + z.object({ + ...menuItemSchema.shape, + submenu: z.array(menuItemSchema).optional(), + }), +); + +export const userMenuSchema = z.object({ + organizations: menuItemsSchema, + workspaces: menuItemsSchema, +}); diff --git a/backend/src/modules/memberships/helpers/create-membership-config.ts b/backend/src/modules/memberships/helpers/create-membership-config.ts new file mode 100644 index 000000000..c3c948a91 --- /dev/null +++ b/backend/src/modules/memberships/helpers/create-membership-config.ts @@ -0,0 +1,30 @@ +import { membershipsTable } from '../../../db/schema/memberships'; +import type { OrganizationModel } from '../../../db/schema/organizations'; +import type { ProjectModel } from '../../../db/schema/projects'; + +/** + * This file provides an abstraction layer for supporting different entities + * where users can be invited to. + * + * It can be extended with more models to support additional entities. + */ + +/** + * Supported types for membership context. + */ +export type supportedModelTypes = OrganizationModel | ProjectModel; + +/** + * Array of supported entity types. + */ +export const supportedEntityTypes = ['PROJECT', 'ORGANIZATION']; + +/** + * Get the memberships table ID based on the context. + * @param context The context for which the memberships table ID is needed. + * @returns The ID of the memberships table column corresponding to the context. + */ +export const membershipsTableId = (context: supportedModelTypes) => { + if (context.entity === 'PROJECT') return membershipsTable.projectId; + return membershipsTable.organizationId; +}; diff --git a/backend/src/modules/memberships/helpers/insert-membership.ts b/backend/src/modules/memberships/helpers/insert-membership.ts new file mode 100644 index 000000000..540f4d28d --- /dev/null +++ b/backend/src/modules/memberships/helpers/insert-membership.ts @@ -0,0 +1,65 @@ +import { eq } from 'drizzle-orm'; +import { type MembershipModel, membershipsTable } from '../../../db/schema/memberships'; +import type { OrganizationModel } from '../../../db/schema/organizations'; +import type { ProjectModel } from '../../../db/schema/projects'; +import { db } from '../../../db/db'; +import { logEvent } from '../../../middlewares/logger/log-event'; +import type { UserModel } from '../../../db/schema/users'; +import type { WorkspaceModel } from '../../../db/schema/workspaces'; + +type UserWithoutPassword = Omit; + +interface Props { + user: UserWithoutPassword; + role: MembershipModel['role']; + entity: OrganizationModel | WorkspaceModel | ProjectModel; + createdBy?: UserWithoutPassword['id']; + memberships?: MembershipModel[]; +} + +// Helper function to insert a membership and give it proper order number +export const insertMembership = async ({ user, role, entity, createdBy = user.id, memberships }: Props) => { + const organizationId = entity.entity === 'ORGANIZATION' ? entity.id : entity.organizationId; + + const newMembership = { + organizationId, + workspaceId: null as string | null, + projectId: null as string | null, + type: entity.entity, + userId: user.id, + role, + createdBy, + order: 1, + }; + + // Set workspaceId or projectId if entity is workspace or project + if (entity.entity === 'WORKSPACE') newMembership.workspaceId = entity.id; + + else if (entity.entity === 'PROJECT') newMembership.projectId = entity.id; + + + // Get user memberships + let userMemberships = memberships; + + if (!memberships) { + userMemberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); + } + + // Set order based on existing memberships for given entity type + if (userMemberships?.length) { + const membershipMaxOrder = userMemberships.reduce((max, current) => { + if (current.type === entity.entity && (!max || current.order > max.order)) return current; + return max; + }); + + newMembership.order = membershipMaxOrder.order + 1; + } + + // Insert + const results = await db.insert(membershipsTable).values(newMembership).returning(); + + // Log + logEvent(`User added to ${entity.entity.toLowerCase()}`, { user: user.id, id: entity.id }); + + return results; +}; diff --git a/backend/src/modules/memberships/helpers/to-membership-info.ts b/backend/src/modules/memberships/helpers/to-membership-info.ts new file mode 100644 index 000000000..b7b2d9287 --- /dev/null +++ b/backend/src/modules/memberships/helpers/to-membership-info.ts @@ -0,0 +1,46 @@ +import type { MembershipModel } from '../../../db/schema/memberships'; +import type { membershipInfoType } from '../schema'; + +/** + * Converts a membership to a membershipInfo object. + * + * @param {MembershipModel} membership - The membership to be converted. + * @returns {membershipInfoType} The converted membership information object. + */ +const convertMembershipToInfo = (membership: MembershipModel): membershipInfoType => ({ + id: membership.id, + role: membership.role, + archived: membership.inactive || false, + muted: membership.muted || false, + order: membership.order, +}); + +/** + * Converts a membership to a membershipInfo object. Handles nullable input. + * + * @param {MembershipModel | undefined | null} membership - The membership to be converted. (Can also be undefined or null). + * @returns {membershipInfoType | null} The converted membership information object, or null if the input is undefined or null. + */ +export const toMembershipInfo = (membership: MembershipModel | undefined | null): membershipInfoType | null => { + return membership ? convertMembershipToInfo(membership) : null; +}; + +/** + * Converts a membership to a membershipInfo object. Handles nullable input. + * + * @param {MembershipModel | undefined | null} membership - The membership to be converted. (Can also be undefined or null). + * @returns {membershipInfoType | null} The converted membership information object, or null if the input is undefined or null. + */ +toMembershipInfo.nullable = (membership: MembershipModel | undefined | null): membershipInfoType | null => { + return membership ? convertMembershipToInfo(membership) : null; +}; + +/** + * Converts a membership to a membershipInfo object. Assumes the input is required. + * + * @param {MembershipModel} membership - The membership to be converted. + * @returns {membershipInfoType} The converted membership information object. + */ +toMembershipInfo.required = (membership: MembershipModel): membershipInfoType => { + return convertMembershipToInfo(membership); +}; diff --git a/backend/src/modules/memberships/index.ts b/backend/src/modules/memberships/index.ts new file mode 100644 index 000000000..41ebf369c --- /dev/null +++ b/backend/src/modules/memberships/index.ts @@ -0,0 +1,370 @@ +import { and, eq, inArray, or } from 'drizzle-orm'; +import { db } from '../../db/db'; +import { type MembershipModel, membershipsTable } from '../../db/schema/memberships'; + +import { render } from '@react-email/render'; +import { config } from 'config'; +import { emailSender } from 'email'; +import { generateId } from 'lucia'; +import { TimeSpan, createDate } from 'oslo'; +import { InviteMemberEmail } from '../../../../email/emails/member-invite'; + +import type { OrganizationModel } from '../../db/schema/organizations'; +import { type TokenModel, tokensTable } from '../../db/schema/tokens'; +import { type UserModel, usersTable } from '../../db/schema/users'; +import { resolveEntity } from '../../lib/entity'; +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import permissionManager from '../../lib/permission-manager'; +import { sendSSEToUsers } from '../../lib/sse'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { membershipsTableId, supportedEntityTypes, type supportedModelTypes } from './helpers/create-membership-config'; +import membershipRouteConfig from './routes'; +import { insertMembership } from './helpers/insert-membership'; +import { toMembershipInfo } from './helpers/to-membership-info'; + +const app = new CustomHono(); + +// Membership endpoints +const membershipsRoutes = app + /* + * Invite members to an entity such as an organization + */ + .openapi(membershipRouteConfig.createMembership, async (ctx) => { + const { idOrSlug, entityType, organizationId } = ctx.req.valid('query'); + const { emails, role } = ctx.req.valid('json'); + const user = ctx.get('user'); + + // Check params + if (!organizationId || !entityType || !supportedEntityTypes.includes(entityType) || !idOrSlug) { + return errorResponse(ctx, 403, 'forbidden', 'warn'); + } + + // Fetch organization, user memberships, and context from the database + const [organization, memberships, context] = await Promise.all([ + resolveEntity('ORGANIZATION', organizationId) as Promise, + db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)) as Promise, + resolveEntity(entityType, idOrSlug) as Promise, + ]); + + // Check if the user is allowed to perform an update action in the organization + const isAllowed = permissionManager.isPermissionAllowed(memberships, 'update', organization); + + if (!context || !organization || (!isAllowed && user.role !== 'ADMIN')) { + return errorResponse(ctx, 403, 'forbidden', 'warn'); + } + + // Normalize emails for consistent comparison + const normalizedEmails = emails.map((email) => email.toLowerCase()); + + // Fetch existing users from the database + const existingUsers = await db.select().from(usersTable).where(inArray(usersTable.email, normalizedEmails)); + + // Maps to store memberships by existing user + const organizationMembershipsByUser = new Map(); + const contextMembershipsByUser = new Map(); + + if (existingUsers.length) { + // Prepare conditions for fetching existing memberships + const $where = [ + and( + eq(membershipsTableId(context), context.id), + eq(membershipsTable.type, context.entity as MembershipModel['type']), + inArray( + membershipsTable.userId, + existingUsers.map((u) => u.id), + ), + ), + ]; + + // Add conditions for organization memberships if applicable + if (context.entity !== 'ORGANIZATION') { + $where.push( + and( + eq(membershipsTable.organizationId, organizationId), + eq(membershipsTable.type, 'ORGANIZATION'), + inArray( + membershipsTable.userId, + existingUsers.map((u) => u.id), + ), + ), + ); + } + + // Query existing memberships + const existingMemberships = await db + .select() + .from(membershipsTable) + .where($where.length > 1 ? or(...$where) : $where[0]); + + // Group memberships by user + for (const membership of existingMemberships) { + if (membership.userId && membership.type === 'ORGANIZATION') organizationMembershipsByUser.set(membership.userId, membership); + if (membership.userId && membership.type === context.entity) contextMembershipsByUser.set(membership.userId, membership); + } + } + + // Map to track existing users + const existingUsersByEmail = new Map(); + + // Array of emails to send invitations + const emailsToSendInvitation: string[] = []; + + // Establish memberships for existing users + await Promise.all( + existingUsers.map(async (existingUser) => { + existingUsersByEmail.set(existingUser.email, existingUser); + + const existingMembership = contextMembershipsByUser.get(existingUser.id); + const organizationMembership = organizationMembershipsByUser.get(existingUser.id); + + if (existingMembership) { + logEvent(`User already member of ${context.entity.toLowerCase()}`, { user: existingUser.id, id: context.id }); + + // Check if the role needs to be updated (downgrade or upgrade) + if (role && existingMembership.role !== role) { + await db + .update(membershipsTable) + .set({ role: role as MembershipModel['role'] }) + .where(eq(membershipsTable.id, existingMembership.id)); + + logEvent('User role updated', { user: existingUser.id, id: context.id, type: existingMembership.type, role }); + } + } else { + // Check if membership creation is allowed and if invitation is needed + const canCreateMembership = context.entity !== 'ORGANIZATION' || existingUser.id === user.id; + const needsInvitation = + (context.entity !== 'ORGANIZATION' && !organizationMembership) || (context.entity === 'ORGANIZATION' && existingUser.id !== user.id); + + if (canCreateMembership) { + const assignedRole = (role as MembershipModel['role']) || 'MEMBER'; + + // Insert membership + await insertMembership({ user: existingUser, role: assignedRole, entity: context, memberships }); + + // Send a Server-Sent Event (SSE) to the newly added user + sendSSEToUsers([existingUser.id], 'update_entity', { + ...context, + membership: toMembershipInfo(memberships.find((m) => m.userId === existingUser.id)), + }); + } + + if (needsInvitation) { + // Add email to the invitation list for sending an organization invite to another user + emailsToSendInvitation.push(existingUser.email); + } + } + }), + ); + + // Identify emails that do not have existing users (will need to send a invitation) + for (const email of normalizedEmails) { + if (!existingUsersByEmail.has(email)) emailsToSendInvitation.push(email); + } + + // Send invitations for organization membership + await Promise.all( + emailsToSendInvitation.map(async (email) => { + const targetUser = existingUsersByEmail.get(email); + + const token = generateId(40); + await db.insert(tokensTable).values({ + id: token, + type: 'ORGANIZATION_INVITATION', + userId: targetUser?.id, + email: email, + role: (role as TokenModel['role']) || 'MEMBER', + organizationId: organization.id, + expiresAt: createDate(new TimeSpan(7, 'd')), + }); + + // Render email template + const emailHtml = render(InviteMemberEmail({ organization, targetUser, user, token })); + + // Log event for user invitation + logEvent('User invited to organization', { organization: organization.id }); + + // Send invitation email + emailSender + .send(config.senderIsReceiver ? user.email : email, `Invitation to ${organization.name} on Cella`, emailHtml, user.email) + .catch((error) => { + logEvent('Error sending email', { error: (error as Error).message }, 'error'); + }); + }), + ); + + return ctx.json({ success: true }, 200); + }) + /* + * Delete memberships to remove users from entity + */ + .openapi(membershipRouteConfig.deleteMemberships, async (ctx) => { + const { idOrSlug, entityType, ids } = ctx.req.valid('query'); + const user = ctx.get('user'); + + if (!config.contextEntityTypes.includes(entityType)) return errorResponse(ctx, 404, 'not_found', 'warn'); + // Convert the member ids to an array + const memberToDeleteIds = Array.isArray(ids) ? ids : [ids]; + + // Check if the user has permission to delete the memberships + const membershipContext = await resolveEntity(entityType, idOrSlug); + const memberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); + + const isAllowed = permissionManager.isPermissionAllowed(memberships, 'update', membershipContext); + + if (!isAllowed && user.role !== 'ADMIN') { + return errorResponse(ctx, 403, 'forbidden', 'warn', entityType, { user: user.id, id: membershipContext.id }); + } + + const errors: ErrorType[] = []; + + const where = and( + eq(membershipsTable.type, entityType), + or( + eq(membershipsTable.organizationId, membershipContext.id), + eq(membershipsTable.workspaceId, membershipContext.id), + eq(membershipsTable.projectId, membershipContext.id), + ), + ); + + // Get the user membership + const [currentUserMembership] = (await db + .select() + .from(membershipsTable) + .where(and(where, eq(membershipsTable.userId, user.id)))) as (MembershipModel | undefined)[]; + + // Get the memberships + const targets = await db + .select() + .from(membershipsTable) + .where(and(inArray(membershipsTable.userId, memberToDeleteIds), where)); + + // Check if the memberships exist + for (const id of memberToDeleteIds) { + if (!targets.some((target) => target.userId === id)) { + errors.push(createError(ctx, 404, 'not_found', 'warn', entityType, { user: id })); + } + } + + // Filter out memberships that the user doesn't have permission to delete + const allowedTargets = targets.filter((target) => { + if (user.role !== 'ADMIN' && currentUserMembership?.role !== 'ADMIN') { + errors.push( + createError(ctx, 403, 'delete_forbidden', 'warn', entityType, { + user: target.userId, + membership: target.id, + }), + ); + return false; + } + + return true; + }); + + // If the user doesn't have permission to delete any of the memberships, return an error + if (allowedTargets.length === 0) { + return ctx.json({ success: false, errors: errors }, 200); + } + + // Delete the memberships + await db.delete(membershipsTable).where( + inArray( + membershipsTable.id, + allowedTargets.map((target) => target.id), + ), + ); + + // Send SSE events for the memberships that were deleted + for (const membership of allowedTargets) { + // Send the event to the user if they are a member of the organization + const memberIds = targets.map((el) => el.userId).filter(Boolean) as string[]; + sendSSEToUsers(memberIds, 'remove_entity', { id: membershipContext.id, entity: membershipContext.entity }); + + logEvent('Member deleted', { membership: membership.id }); + } + + return ctx.json({ success: true, errors }, 200); + }) + /* + * Update user membership + */ + .openapi(membershipRouteConfig.updateMembership, async (ctx) => { + const { id: membershipId } = ctx.req.valid('param'); + const { role, inactive, muted, order } = ctx.req.valid('json'); + const user = ctx.get('user'); + + // Get the membership + const [membershipToUpdate] = await db.select().from(membershipsTable).where(eq(membershipsTable.id, membershipId)); + if (!membershipToUpdate) return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { membership: membershipId }); + + const updatedType = membershipToUpdate.type; + + // TODO: Refactor + const membershipContext = await resolveEntity( + updatedType, + membershipToUpdate.projectId || membershipToUpdate.workspaceId || membershipToUpdate.organizationId || '', + ); + + // Check if user has permission to someone elses membership + if (user.id !== membershipToUpdate.userId) { + const permissionMemberships = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.type, updatedType), eq(membershipsTable.userId, user.id))); + const isAllowed = permissionManager.isPermissionAllowed(permissionMemberships, 'update', membershipContext); + if (!isAllowed && user.role !== 'ADMIN') { + return errorResponse(ctx, 403, 'forbidden', 'warn', updatedType, { user: user.id, id: membershipContext.id }); + } + } + + const [updatedMembership] = await db + .update(membershipsTable) + .set({ + role, + ...(order !== undefined && { order }), + ...(muted !== undefined && { muted }), + ...(inactive !== undefined && { inactive }), + modifiedBy: user.id, + modifiedAt: new Date(), + }) + .where(and(eq(membershipsTable.id, membershipId))) + .returning(); + + const allMembers = await db + .select({ id: membershipsTable.userId }) + .from(membershipsTable) + .where( + and( + eq(membershipsTable.type, updatedType), + or( + eq(membershipsTable.organizationId, membershipContext.id), + eq(membershipsTable.workspaceId, membershipContext.id), + eq(membershipsTable.projectId, membershipContext.id), + ), + ), + ); + + const membersIds = allMembers.map((member) => member.id).filter(Boolean) as string[]; + sendSSEToUsers(membersIds, 'update_entity', { + ...membershipContext, + membership: { + ...updatedMembership, + archived: updatedMembership.inactive, + }, + }); + + logEvent('Membership updated', { user: updatedMembership.userId, membership: updatedMembership.id }); + + return ctx.json( + { + success: true, + data: { + ...updatedMembership, + archived: updatedMembership.inactive, + }, + }, + 200, + ); + }); + +export default membershipsRoutes; diff --git a/backend/src/modules/memberships/routes.ts b/backend/src/modules/memberships/routes.ts new file mode 100644 index 000000000..12f479a5f --- /dev/null +++ b/backend/src/modules/memberships/routes.ts @@ -0,0 +1,99 @@ +import { z } from '@hono/zod-openapi'; + +import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithoutDataSchema } from '../../lib/common-responses'; +import { idSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAuthenticated } from '../../middlewares/guard'; +import { + membershipSchema, + createMembershipBodySchema, + createMembershipQuerySchema, + deleteMembersQuerySchema, + updateMembershipBodySchema, +} from './schema'; + +class MembershipRoutesConfig { + public createMembership = createRouteConfig({ + method: 'post', + path: '/', + guard: isAuthenticated, + tags: ['memberships'], + summary: 'Invite members', + description: 'Invite members to an entity such as an organization.', + request: { + query: createMembershipQuerySchema, + body: { + content: { + 'application/json': { + schema: createMembershipBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Invitation was sent', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteMemberships = createRouteConfig({ + method: 'delete', + path: '/', + guard: isAuthenticated, + tags: ['memberships'], + summary: 'Delete memberships', + description: 'Delete memberships by their ids. This will remove the membership but not delete any user(s).', + request: { + query: deleteMembersQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); + + public updateMembership = createRouteConfig({ + method: 'put', + path: '/{id}', + guard: isAuthenticated, + tags: ['memberships'], + summary: 'Update membership', + description: 'Update role, muted, or archived status in a membership.', + request: { + params: z.object({ id: idSchema }), + body: { + content: { + 'application/json': { + schema: updateMembershipBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Membership updated', + content: { + 'application/json': { + schema: successWithDataSchema(membershipSchema), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new MembershipRoutesConfig(); diff --git a/backend/src/modules/memberships/schema.ts b/backend/src/modules/memberships/schema.ts new file mode 100644 index 000000000..506b9ab91 --- /dev/null +++ b/backend/src/modules/memberships/schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +import { createSelectSchema } from 'drizzle-zod'; +import { membershipsTable } from '../../db/schema/memberships'; +import { contextEntityTypeSchema, idOrSlugSchema, idSchema, idsQuerySchema } from '../../lib/common-schemas'; +import { userSchema } from '../users/schema'; + +const membershipTableSchema = createSelectSchema(membershipsTable); + +export const membershipSchema = membershipTableSchema.extend({ + inactive: z.boolean(), + muted: z.boolean(), + createdAt: z.string(), + modifiedAt: z.string().nullable(), +}); + +export const createMembershipBodySchema = z.object({ + emails: userSchema.shape.email.array().min(1), + role: membershipSchema.shape.role, +}); + +export const updateMembershipBodySchema = z.object({ + role: membershipTableSchema.shape.role.optional(), + muted: z.boolean().optional(), + inactive: z.boolean().optional(), + order: z.number().optional(), +}); + +const baseMembersQuerySchema = z.object({ + idOrSlug: idOrSlugSchema, + entityType: contextEntityTypeSchema, +}); + +export const createMembershipQuerySchema = baseMembersQuerySchema.extend({ organizationId: idSchema }); + +export const deleteMembersQuerySchema = baseMembersQuerySchema.extend(idsQuerySchema.shape); + +export const membershipInfoSchema = z.object({ + id: membershipTableSchema.shape.id, + role: membershipTableSchema.shape.role, + archived: membershipTableSchema.shape.inactive, + muted: membershipTableSchema.shape.muted, + order: membershipTableSchema.shape.order, +}); + +export type membershipInfoType = z.infer; diff --git a/backend/src/modules/organizations/index.ts b/backend/src/modules/organizations/index.ts new file mode 100644 index 000000000..a4c297f21 --- /dev/null +++ b/backend/src/modules/organizations/index.ts @@ -0,0 +1,288 @@ +import { type SQL, and, count, eq, ilike, inArray } from 'drizzle-orm'; +import { db } from '../../db/db'; +import { membershipsTable } from '../../db/schema/memberships'; +import { organizationsTable } from '../../db/schema/organizations'; + +import { config } from 'config'; +import { counts } from '../../lib/counts'; +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import { getOrderColumn } from '../../lib/order-column'; +import { sendSSEToUsers } from '../../lib/sse'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { checkSlugAvailable } from '../general/helpers/check-slug'; +import { toMembershipInfo } from '../memberships/helpers/to-membership-info'; +import organizationRoutesConfig from './routes'; +import { insertMembership } from '../memberships/helpers/insert-membership'; + +const app = new CustomHono(); + +// Organization endpoints +const organizationsRoutes = app + /* + * Create organization + */ + .openapi(organizationRoutesConfig.createOrganization, async (ctx) => { + const { name, slug } = ctx.req.valid('json'); + const user = ctx.get('user'); + + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'ORGANIZATION', { slug }); + } + + const [createdOrganization] = await db + .insert(organizationsTable) + .values({ + name, + shortName: name, + slug, + languages: [config.defaultLanguage], + defaultLanguage: config.defaultLanguage, + createdBy: user.id, + }) + .returning(); + + logEvent('Organization created', { organization: createdOrganization.id }); + + // Insert membership + const [createdMembership] = await insertMembership({ user, role: 'ADMIN', entity: createdOrganization }); + + return ctx.json( + { + success: true, + data: { + ...createdOrganization, + membership: toMembershipInfo(createdMembership), + counts: { + memberships: { + admins: 1, + members: 1, + total: 1, + }, + }, + }, + }, + 200, + ); + }) + /* + * Get list of organizations + */ + .openapi(organizationRoutesConfig.getOrganizations, async (ctx) => { + const { q, sort, order, offset, limit } = ctx.req.valid('query'); + const user = ctx.get('user'); + + const filter: SQL | undefined = q ? ilike(organizationsTable.name, `%${q}%`) : undefined; + + const organizationsQuery = db.select().from(organizationsTable).where(filter); + + const [{ total }] = await db.select({ total: count() }).from(organizationsQuery.as('organizations')); + + const memberships = db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, 'ORGANIZATION'))) + .as('memberships'); + + const orderColumn = getOrderColumn( + { + id: organizationsTable.id, + name: organizationsTable.name, + createdAt: organizationsTable.createdAt, + userRole: memberships.role, + }, + sort, + organizationsTable.id, + order, + ); + + const countsQuery = await counts('ORGANIZATION'); + + const organizations = await db + .select({ + organization: organizationsTable, + membership: membershipsTable, + admins: countsQuery.admins, + members: countsQuery.members, + }) + .from(organizationsQuery.as('organizations')) + .leftJoin(memberships, eq(organizationsTable.id, memberships.organizationId)) + .leftJoin(countsQuery, eq(organizationsTable.id, countsQuery.id)) + .orderBy(orderColumn) + .limit(Number(limit)) + .offset(Number(offset)); + + return ctx.json( + { + success: true, + data: { + items: organizations.map(({ organization, membership, admins, members }) => ({ + ...organization, + membership: toMembershipInfo(membership), + counts: { + memberships: { + admins, + members, + total: members, + }, + }, + })), + total, + }, + }, + 200, + ); + }) + /* + * Update an organization by id or slug + */ + .openapi(organizationRoutesConfig.updateOrganization, async (ctx) => { + const user = ctx.get('user'); + const organization = ctx.get('organization'); + + const { + name, + slug, + shortName, + country, + timezone, + defaultLanguage, + languages, + notificationEmail, + emailDomains, + color, + thumbnailUrl, + logoUrl, + bannerUrl, + websiteUrl, + welcomeText, + authStrategies, + chatSupport, + } = ctx.req.valid('json'); + + if (slug && slug !== organization.slug) { + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'ORGANIZATION', { slug }); + } + } + + const [updatedOrganization] = await db + .update(organizationsTable) + .set({ + name, + slug, + shortName, + country, + timezone, + defaultLanguage, + languages, + notificationEmail, + emailDomains, + color, + thumbnailUrl, + logoUrl, + bannerUrl, + websiteUrl, + welcomeText, + authStrategies, + chatSupport, + modifiedAt: new Date(), + modifiedBy: user.id, + }) + .where(eq(organizationsTable.id, organization.id)) + .returning(); + + const memberships = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'ORGANIZATION'), eq(membershipsTable.organizationId, organization.id))); + + if (memberships.length > 0) { + memberships.map((member) => + sendSSEToUsers([member.id], 'update_entity', { + ...updatedOrganization, + membership: toMembershipInfo(memberships.find((m) => m.id === member.id)), + }), + ); + } + + logEvent('Organization updated', { organization: updatedOrganization.id }); + + return ctx.json( + { + success: true, + data: { + ...updatedOrganization, + membership: toMembershipInfo(memberships.find((m) => m.id === user.id)), + counts: await counts('ORGANIZATION', organization.id), + }, + }, + 200, + ); + }) + /* + * Get organization by id or slug + */ + .openapi(organizationRoutesConfig.getOrganization, async (ctx) => { + const user = ctx.get('user'); + const organization = ctx.get('organization'); + + const [membership] = await db + .select() + .from(membershipsTable) + .where( + and(eq(membershipsTable.userId, user.id), eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.type, 'ORGANIZATION')), + ); + + return ctx.json( + { + success: true, + data: { + ...organization, + membership: toMembershipInfo(membership), + counts: await counts('ORGANIZATION', organization.id), + }, + }, + 200, + ); + }) + + /* + * Delete organizations by ids + */ + .openapi(organizationRoutesConfig.deleteOrganizations, async (ctx) => { + // Extract allowed and disallowed ids + const allowedIds = ctx.get('allowedIds'); + const disallowedIds = ctx.get('disallowedIds'); + + // Map errors of workspaces user is not allowed to delete + const errors: ErrorType[] = disallowedIds.map((id) => createError(ctx, 404, 'not_found', 'warn', 'ORGANIZATION', { organization: id })); + + // Get members + const organizationsMembers = await db + .select({ id: membershipsTable.userId }) + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'ORGANIZATION'), inArray(membershipsTable.organizationId, allowedIds))); + + // Delete the organizations + await db.delete(organizationsTable).where(inArray(organizationsTable.id, allowedIds)); + + // Send SSE events for the organizations that were deleted + for (const id of allowedIds) { + // Send the event to the user if they are a member of the organization + if (organizationsMembers.length > 0) { + const membersId = organizationsMembers.map((member) => member.id).filter(Boolean) as string[]; + sendSSEToUsers(membersId, 'remove_entity', { id, entity: 'ORGANIZATION' }); + } + + logEvent('Organization deleted', { organization: id }); + } + + return ctx.json({ success: true, errors: errors }, 200); + }); + +export default organizationsRoutes; diff --git a/backend/src/modules/organizations/routes.ts b/backend/src/modules/organizations/routes.ts new file mode 100644 index 000000000..cba72d7d4 --- /dev/null +++ b/backend/src/modules/organizations/routes.ts @@ -0,0 +1,137 @@ +import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithPaginationSchema } from '../../lib/common-responses'; +import { idsQuerySchema, entityParamSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAllowedTo, isAuthenticated, isSystemAdmin, splitByAllowance } from '../../middlewares/guard'; +import { organizationSchema, createOrganizationBodySchema, getOrganizationsQuerySchema, updateOrganizationBodySchema } from './schema'; + +class OrganizationRoutesConfig { + public createOrganization = createRouteConfig({ + method: 'post', + path: '/', + guard: isAuthenticated, + tags: ['organizations'], + summary: 'Create new organization', + description: 'Create a new organization.', + request: { + body: { + required: true, + content: { + 'application/json': { + schema: createOrganizationBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Organization was createRouteConfigd', + content: { + 'application/json': { + schema: successWithDataSchema(organizationSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getOrganizations = createRouteConfig({ + method: 'get', + path: '/', + guard: [isAuthenticated, isSystemAdmin], + tags: ['organizations'], + summary: 'Get list of organizations', + description: 'Get list of organizations. Currently only available to system admins.', + request: { + query: getOrganizationsQuerySchema, + }, + responses: { + 200: { + description: 'Organizations', + content: { + 'application/json': { + schema: successWithPaginationSchema(organizationSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public updateOrganization = createRouteConfig({ + method: 'put', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('update', 'ORGANIZATION')], + tags: ['organizations'], + summary: 'Update organization', + description: 'Update organization by id or slug.', + request: { + params: entityParamSchema, + body: { + content: { + 'application/json': { + schema: updateOrganizationBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Organization was updated', + content: { + 'application/json': { + schema: successWithDataSchema(organizationSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getOrganization = createRouteConfig({ + method: 'get', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('read', 'ORGANIZATION')], + tags: ['organizations'], + summary: 'Get organization', + description: 'Get an organization by id or slug.', + request: { + params: entityParamSchema, + }, + responses: { + 200: { + description: 'Organization', + content: { + 'application/json': { + schema: successWithDataSchema(organizationSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteOrganizations = createRouteConfig({ + method: 'delete', + path: '/', + guard: [isAuthenticated, splitByAllowance('delete', 'organization')], + tags: ['organizations'], + summary: 'Delete organizations', + description: 'Delete organizations by ids.', + request: { + query: idsQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new OrganizationRoutesConfig(); diff --git a/backend/src/modules/organizations/schema.ts b/backend/src/modules/organizations/schema.ts new file mode 100644 index 000000000..1199af6f1 --- /dev/null +++ b/backend/src/modules/organizations/schema.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { organizationsTable } from '../../db/schema/organizations'; +import { + membershipsCountSchema, + imageUrlSchema, + nameSchema, + paginationQuerySchema, + validDomainsSchema, + validSlugSchema, + validUrlSchema, +} from '../../lib/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; + +export const organizationSchema = z.object({ + ...createSelectSchema(organizationsTable).shape, + createdAt: z.string(), + modifiedAt: z.string().nullable(), + languages: z.array(z.string()), + emailDomains: z.array(z.string()), + authStrategies: z.array(z.string()), + membership: membershipInfoSchema.nullable(), + counts: membershipsCountSchema, +}); + +export const createOrganizationBodySchema = z.object({ + name: nameSchema, + slug: validSlugSchema, +}); + +export const updateOrganizationBodySchema = createInsertSchema(organizationsTable, { + slug: validSlugSchema, + name: nameSchema, + shortName: nameSchema, + languages: z.array(z.string()).min(1).optional(), + emailDomains: validDomainsSchema, + authStrategies: z.array(z.string()).optional(), + websiteUrl: validUrlSchema, + thumbnailUrl: imageUrlSchema, + bannerUrl: imageUrlSchema, + logoUrl: imageUrlSchema, +}) + .pick({ + slug: true, + name: true, + shortName: true, + country: true, + timezone: true, + defaultLanguage: true, + languages: true, + notificationEmail: true, + emailDomains: true, + color: true, + thumbnailUrl: true, + logoUrl: true, + bannerUrl: true, + websiteUrl: true, + welcomeText: true, + authStrategies: true, + chatSupport: true, + }) + .partial(); + +export const getOrganizationsQuerySchema = paginationQuerySchema.merge( + z.object({ + sort: z.enum(['id', 'name', 'userRole', 'createdAt']).default('createdAt').optional(), + }), +); diff --git a/backend/src/modules/projects/index.ts b/backend/src/modules/projects/index.ts new file mode 100644 index 000000000..c0d393525 --- /dev/null +++ b/backend/src/modules/projects/index.ts @@ -0,0 +1,297 @@ +import { type SQL, and, eq, ilike, inArray } from 'drizzle-orm'; +import { db } from '../../db/db'; +import { membershipsTable } from '../../db/schema/memberships'; +import { projectsTable } from '../../db/schema/projects'; +import { projectsToWorkspacesTable } from '../../db/schema/projects-to-workspaces'; + +import { counts } from '../../lib/counts'; +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import { getOrderColumn } from '../../lib/order-column'; +import { sendSSEToUsers } from '../../lib/sse'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { checkSlugAvailable } from '../general/helpers/check-slug'; +import { toMembershipInfo } from '../memberships/helpers/to-membership-info'; +import projectRoutesConfig from './routes'; +import { insertMembership } from '../memberships/helpers/insert-membership'; + +const app = new CustomHono(); + +// Project endpoints +const projectsRoutes = app + /* + * Create project + */ + .openapi(projectRoutesConfig.createProject, async (ctx) => { + const { name, slug, color, organizationId } = ctx.req.valid('json'); + const workspaceId = ctx.req.query('workspaceId'); + + const user = ctx.get('user'); + const memberships = ctx.get('memberships'); + + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'PROJECT', { slug }); + } + + const [project] = await db + .insert(projectsTable) + .values({ + organizationId, + name, + slug, + color, + createdBy: user.id, + }) + .returning(); + + logEvent('Project created', { project: project.id }); + + // Insert membership + const [createdMembership] = await insertMembership({ user, role: 'ADMIN', entity: project, memberships }); + + // If project created in workspace, add project to it + if (workspaceId) { + await db.insert(projectsToWorkspacesTable).values({ + projectId: project.id, + workspaceId: workspaceId, + }); + + logEvent('Project added to workspace', { project: project.id, workspace: workspaceId }); + } + + const createdProject = { + ...project, + workspaceId, + counts: { + memberships: { admins: 1, members: 1, total: 1 }, + }, + membership: toMembershipInfo(createdMembership), + }; + + return ctx.json({ success: true, data: createdProject }, 200); + }) + /* + * Get project by id or slug + */ + .openapi(projectRoutesConfig.getProject, async (ctx) => { + const project = ctx.get('project'); + const memberships = ctx.get('memberships'); + const membership = memberships.find((m) => m.projectId === project.id && m.type === 'PROJECT'); + + return ctx.json( + { + success: true, + data: { + ...project, + workspaceId: membership?.workspaceId, + membership: toMembershipInfo(membership), + counts: await counts('PROJECT', project.id), + }, + }, + 200, + ); + }) + /* + * Get list of projects + */ + .openapi(projectRoutesConfig.getProjects, async (ctx) => { + // TODO: also be able to filter on organizationId + const { q, sort, order, offset, limit, workspaceId, requestedUserId } = ctx.req.valid('query'); + const user = ctx.get('user'); + + const filter: SQL | undefined = q ? ilike(projectsTable.name, `%${q}%`) : undefined; + const projectsFilters = [filter]; + + const projectsQuery = db + .select() + .from(projectsTable) + .where(and(...projectsFilters)); + + const countsQuery = await counts('PROJECT'); + + // @TODO: Permission check which projects a user is allowed to see? (this will skip when requestedUserId is used in query!) + // It should check organization permissions, project permissions and system admin permission + const memberships = db + .select() + .from(membershipsTable) + .where(eq(membershipsTable.userId, requestedUserId ? requestedUserId : user.id)) + .as('memberships'); + + const orderColumn = getOrderColumn( + { + id: projectsTable.id, + name: projectsTable.name, + createdAt: projectsTable.createdAt, + userRole: memberships.role, + }, + sort, + projectsTable.id, + order, + ); + + // biome-ignore lint/suspicious/noExplicitAny: + let projects: Array; + + if (!workspaceId) { + projects = await db + .select({ + project: projectsTable, + membership: membershipsTable, + workspaceId: projectsToWorkspacesTable.workspaceId, + admins: countsQuery.admins, + members: countsQuery.members, + }) + .from(projectsQuery.as('projects')) + .innerJoin(memberships, eq(memberships.projectId, projectsTable.id)) + .leftJoin(projectsToWorkspacesTable, eq(projectsToWorkspacesTable.projectId, projectsTable.id)) + .leftJoin(countsQuery, eq(projectsTable.id, countsQuery.id)) + .orderBy(orderColumn) + .limit(Number(limit)) + .offset(Number(offset)); + } else { + projects = await db + .select({ + project: projectsTable, + membership: membershipsTable, + workspaceId: projectsToWorkspacesTable.workspaceId, + admins: countsQuery.admins, + members: countsQuery.members, + }) + .from(projectsToWorkspacesTable) + .leftJoin( + projectsTable, + and(eq(projectsToWorkspacesTable.projectId, projectsTable.id), eq(projectsToWorkspacesTable.workspaceId, workspaceId), ...projectsFilters), + ) + .leftJoin(countsQuery, eq(projectsTable.id, countsQuery.id)) + .leftJoin(memberships, and(eq(memberships.projectId, projectsTable.id))) + .where(eq(projectsToWorkspacesTable.workspaceId, workspaceId)) + .orderBy(orderColumn) + .limit(Number(limit)) + .offset(Number(offset)); + } + + return ctx.json( + { + success: true, + data: { + items: projects.map(({ project, membership, workspaceId, admins, members }) => ({ + ...project, + membership: toMembershipInfo(membership), + workspaceId, + counts: { + memberships: { admins, members }, + }, + })), + total: projects.length, + }, + }, + 200, + ); + }) + /* + * Update project + */ + .openapi(projectRoutesConfig.updateProject, async (ctx) => { + const user = ctx.get('user'); + const project = ctx.get('project'); + + const { name, slug, color, workspaceId } = ctx.req.valid('json'); + + if (slug && slug !== project.slug) { + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'PROJECT', { slug }); + } + } + + const [updatedProject] = await db + .update(projectsTable) + .set({ + name, + slug, + color, + modifiedAt: new Date(), + modifiedBy: user.id, + }) + .where(eq(projectsTable.id, project.id)) + .returning(); + + const [workspaceRelation] = await db.select().from(projectsToWorkspacesTable).where(eq(projectsToWorkspacesTable.projectId, project.id)); + if (workspaceId && workspaceRelation.workspaceId !== workspaceId) { + await db.update(projectsToWorkspacesTable).set({ + projectId: project.id, + workspaceId, + }); + } + + const memberships = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'PROJECT'), eq(membershipsTable.projectId, project.id))); + + if (memberships.length > 0) { + memberships.map((member) => + sendSSEToUsers([member.id], 'update_entity', { + ...updatedProject, + membership: toMembershipInfo(memberships.find((m) => m.id === member.id)), + }), + ); + } + + logEvent('Project updated', { project: updatedProject.id }); + + return ctx.json( + { + success: true, + data: { + ...updatedProject, + parentId: workspaceId, + membership: toMembershipInfo(memberships.find((m) => m.id === user.id)), + counts: await counts('PROJECT', project.id), + }, + }, + 200, + ); + }) + + /* + * Delete projects + */ + .openapi(projectRoutesConfig.deleteProjects, async (ctx) => { + // Extract allowed and disallowed ids + const allowedIds = ctx.get('allowedIds'); + const disallowedIds = ctx.get('disallowedIds'); + + // Map errors of workspaces user is not allowed to delete + const errors: ErrorType[] = disallowedIds.map((id) => createError(ctx, 404, 'not_found', 'warn', 'PROJECT', { project: id })); + + // Get members + const projectsMembers = await db + .select({ id: membershipsTable.userId, projectId: membershipsTable.projectId }) + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'PROJECT'), inArray(membershipsTable.projectId, allowedIds))); + + // Delete the projectId + await db.delete(projectsTable).where(inArray(projectsTable.id, allowedIds)); + + // Send SSE events for the projects that were deleted + for (const id of allowedIds) { + // Send the event to the user if they are a member of the project + if (projectsMembers.length > 0) { + const membersId = projectsMembers + .filter(({ projectId }) => projectId === id) + .map((member) => member.id) + .filter(Boolean) as string[]; + sendSSEToUsers(membersId, 'remove_entity', { id, entity: 'PROJECT' }); + } + + logEvent('Project deleted', { project: id }); + } + + return ctx.json({ success: true, errors: errors }, 200); + }); + +export default projectsRoutes; diff --git a/backend/src/modules/projects/routes.ts b/backend/src/modules/projects/routes.ts new file mode 100644 index 000000000..959dbe235 --- /dev/null +++ b/backend/src/modules/projects/routes.ts @@ -0,0 +1,140 @@ +import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithPaginationSchema } from '../../lib/common-responses'; +import { idsQuerySchema, entityParamSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAllowedTo, isAuthenticated, splitByAllowance } from '../../middlewares/guard'; + +import { projectSchema, createProjectBodySchema, createProjectQuerySchema, getProjectsQuerySchema, updateProjectBodySchema } from './schema'; + +class ProjectRoutesConfig { + public createProject = createRouteConfig({ + method: 'post', + path: '/', + guard: [isAuthenticated, isAllowedTo('create', 'PROJECT')], + tags: ['projects'], + summary: 'Create new project', + description: 'Create a new project in an organization. Creator will become admin and can invite other members.', + security: [{ bearerAuth: [] }], + request: { + query: createProjectQuerySchema, + body: { + required: true, + content: { + 'application/json': { + schema: createProjectBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Project', + content: { + 'application/json': { + schema: successWithDataSchema(projectSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getProject = createRouteConfig({ + method: 'get', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('read', 'PROJECT')], + tags: ['projects'], + summary: 'Get project', + description: 'Get project by id or slug.', + request: { + params: entityParamSchema, + }, + responses: { + 200: { + description: 'Project', + content: { + 'application/json': { + schema: successWithDataSchema(projectSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getProjects = createRouteConfig({ + method: 'get', + path: '/', + guard: isAuthenticated, + tags: ['projects'], + summary: 'Get list of projects', + description: 'Get list of projects in which you have a membership or - if a `requestedUserId` is provided - the projects of this user.', + request: { + query: getProjectsQuerySchema, + }, + responses: { + 200: { + description: 'Projects', + content: { + 'application/json': { + schema: successWithPaginationSchema(projectSchema), + }, + }, + ...errorResponses, + }, + }, + }); + + public updateProject = createRouteConfig({ + method: 'put', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('update', 'PROJECT')], + tags: ['projects'], + summary: 'Update project', + description: 'Update project by id or slug.', + request: { + params: entityParamSchema, + body: { + content: { + 'application/json': { + schema: updateProjectBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Project updated', + content: { + 'application/json': { + schema: successWithDataSchema(projectSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteProjects = createRouteConfig({ + method: 'delete', + path: '/', + guard: [isAuthenticated, splitByAllowance('delete', 'project')], + tags: ['projects'], + summary: 'Delete projects', + description: 'Delete projects by ids.', + request: { + query: idsQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new ProjectRoutesConfig(); diff --git a/backend/src/modules/projects/schema.ts b/backend/src/modules/projects/schema.ts new file mode 100644 index 000000000..c073f3f43 --- /dev/null +++ b/backend/src/modules/projects/schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { projectsTable } from '../../db/schema/projects'; +import { colorSchema, membershipsCountSchema, idSchema, nameSchema, paginationQuerySchema, validSlugSchema } from '../../lib/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; + +export const projectSchema = z.object({ + ...createSelectSchema(projectsTable).shape, + createdAt: z.string(), + modifiedAt: z.string().nullable(), + membership: membershipInfoSchema.nullable(), + workspaceId: z.string().nullish(), + counts: membershipsCountSchema, +}); + +export const createProjectBodySchema = z.object({ + name: nameSchema, + slug: validSlugSchema, + color: colorSchema, + organizationId: idSchema, +}); + +export const createProjectQuerySchema = z.object({ + workspaceId: idSchema.optional(), +}); + +export const getProjectsQuerySchema = paginationQuerySchema.merge( + z.object({ + sort: z.enum(['id', 'name', 'userRole', 'createdAt']).default('createdAt').optional(), + organizationId: idSchema.optional(), + workspaceId: idSchema.optional(), + requestedUserId: idSchema.optional(), + }), +); + +export const updateProjectBodySchema = createInsertSchema(projectsTable, { + slug: validSlugSchema, + name: nameSchema, + color: colorSchema, +}) + .pick({ + slug: true, + name: true, + color: true, + }) + .merge( + z.object({ + workspaceId: idSchema.nullable(), + }), + ); diff --git a/backend/src/modules/requests/index.ts b/backend/src/modules/requests/index.ts new file mode 100644 index 000000000..ac0ce89dd --- /dev/null +++ b/backend/src/modules/requests/index.ts @@ -0,0 +1,74 @@ +import { type SQL, count, ilike } from 'drizzle-orm'; + +import { db } from '../../db/db'; +import { requestsTable } from '../../db/schema/requests'; +import { sendSlackNotification } from '../../lib/notification'; +import { getOrderColumn } from '../../lib/order-column'; +import { CustomHono } from '../../types/common'; +import requestsRoutesConfig from './routes'; + +const app = new CustomHono(); + +// Requests endpoints +const requestsRoutes = app + /* + * Create request + */ + .openapi(requestsRoutesConfig.createRequest, async (ctx) => { + const { email, type, message } = ctx.req.valid('json'); + + const [createdAccessRequest] = await db + .insert(requestsTable) + .values({ + email, + type, + message: message, + }) + .returning(); + + // slack notifications + if (type === 'WAITLIST_REQUEST') await sendSlackNotification('to join the waitlist.', email); + if (type === 'NEWSLETTER_REQUEST') await sendSlackNotification('to become a donate or build member.', email); + if (type === 'CONTACT_REQUEST') await sendSlackNotification(`for contact from ${message}.`, email); + + return ctx.json( + { + success: true, + data: { + email: createdAccessRequest.email, + type: createdAccessRequest.type, + }, + }, + 200, + ); + }) + /* + * Get list of requests for system admins + */ + .openapi(requestsRoutesConfig.getRequests, async (ctx) => { + const { q, sort, order, offset, limit } = ctx.req.valid('query'); + + const filter: SQL | undefined = q ? ilike(requestsTable.email, `%${q}%`) : undefined; + + const requestsQuery = db.select().from(requestsTable).where(filter); + + const [{ total }] = await db.select({ total: count() }).from(requestsQuery.as('requests')); + + const orderColumn = getOrderColumn( + { + id: requestsTable.id, + email: requestsTable.email, + createdAt: requestsTable.createdAt, + type: requestsTable.type, + }, + sort, + requestsTable.id, + order, + ); + + const items = await db.select().from(requestsQuery.as('requests')).orderBy(orderColumn).limit(Number(limit)).offset(Number(offset)); + + return ctx.json({ success: true, data: { items, total } }, 200); + }); + +export default requestsRoutes; diff --git a/backend/src/modules/requests/routes.ts b/backend/src/modules/requests/routes.ts new file mode 100644 index 000000000..42eb38927 --- /dev/null +++ b/backend/src/modules/requests/routes.ts @@ -0,0 +1,61 @@ +import { errorResponses, successWithDataSchema, successWithPaginationSchema } from '../../lib/common-responses'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAuthenticated, isPublicAccess, isSystemAdmin } from '../../middlewares/guard'; +import { authRateLimiter } from '../../middlewares/rate-limiter'; +import { requestsInfoSchema, createRequestSchema, getRequestsQuerySchema, requestsSchema } from './schema'; + +class RequestsRoutesConfig { + public createRequest = createRouteConfig({ + method: 'post', + path: '/', + guard: isPublicAccess, + middleware: [authRateLimiter], + tags: ['requests'], + summary: 'Create request', + description: 'Create a request on system level. Request supports waitlist, contact form and newsletter.', + request: { + body: { + content: { + 'application/json': { + schema: createRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Requests', + content: { + 'application/json': { + schema: successWithDataSchema(requestsSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getRequests = createRouteConfig({ + method: 'get', + path: '/', + guard: [isAuthenticated, isSystemAdmin], + tags: ['requests'], + summary: 'Get list of requests', + description: 'Get list of requests on system level for waitlist, contact form or newsletter.', + request: { + query: getRequestsQuerySchema, + }, + responses: { + 200: { + description: 'Requests', + content: { + 'application/json': { + schema: successWithPaginationSchema(requestsInfoSchema), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new RequestsRoutesConfig(); diff --git a/backend/src/modules/requests/schema.ts b/backend/src/modules/requests/schema.ts new file mode 100644 index 000000000..1549524de --- /dev/null +++ b/backend/src/modules/requests/schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { createSelectSchema } from 'drizzle-zod'; +import { requestsTable } from '../../db/schema/requests'; +import { paginationQuerySchema } from '../../lib/common-schemas'; + +const requestsTableSchema = createSelectSchema(requestsTable); + +export const requestsSchema = z.object({ + email: z.string().min(1).email(), + type: requestsTableSchema.shape.type, +}); + +export const createRequestSchema = requestsSchema.extend({ message: z.string().nullable() }); + +export const requestsInfoSchema = requestsTableSchema.extend({ createdAt: z.string() }); + +export const getRequestsQuerySchema = paginationQuerySchema.merge( + z.object({ + sort: z.enum(['id', 'email', 'type', 'createdAt']).default('createdAt').optional(), + }), +); diff --git a/backend/src/modules/users/helpers/transform-database-user.ts b/backend/src/modules/users/helpers/transform-database-user.ts new file mode 100644 index 000000000..23899ce7c --- /dev/null +++ b/backend/src/modules/users/helpers/transform-database-user.ts @@ -0,0 +1,28 @@ +import type { UserModel } from '../../../db/schema/users'; + +type MakeOptional = Omit & Partial>; + +export const transformDatabaseUserWithCount = ({ hashedPassword, ...user }: MakeOptional, memberships: number) => { + return { + ...user, + lastSeenAt: user.lastSeenAt?.toISOString() ?? null, + lastVisitAt: user.lastVisitAt?.toISOString() ?? null, + lastSignInAt: user.lastSignInAt?.toISOString() ?? null, + createdAt: user.createdAt.toISOString(), + modifiedAt: user.modifiedAt?.toISOString() ?? null, + counts: { + memberships: memberships, + }, + }; +}; + +export const transformDatabaseUser = ({ hashedPassword, ...user }: MakeOptional) => { + return { + ...user, + lastSeenAt: user.lastSeenAt?.toISOString() ?? null, + lastVisitAt: user.lastVisitAt?.toISOString() ?? null, + lastSignInAt: user.lastSignInAt?.toISOString() ?? null, + createdAt: user.createdAt.toISOString(), + modifiedAt: user.modifiedAt?.toISOString() ?? null, + }; +}; diff --git a/backend/src/modules/users/index.ts b/backend/src/modules/users/index.ts new file mode 100644 index 000000000..3a36961d5 --- /dev/null +++ b/backend/src/modules/users/index.ts @@ -0,0 +1,256 @@ +import { and, count, eq, ilike, inArray, or } from 'drizzle-orm'; + +import type { User } from 'lucia'; +import { coalesce, db } from '../../db/db'; +import { auth } from '../../db/lucia'; +import { membershipsTable } from '../../db/schema/memberships'; +import { usersTable } from '../../db/schema/users'; +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import { getOrderColumn } from '../../lib/order-column'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { removeSessionCookie } from '../auth/helpers/cookies'; +import { checkSlugAvailable } from '../general/helpers/check-slug'; +import { transformDatabaseUserWithCount } from './helpers/transform-database-user'; +import usersRoutesConfig from './routes'; + +const app = new CustomHono(); + +// User endpoints +const usersRoutes = app + /* + * Get list of users + */ + .openapi(usersRoutesConfig.getUsers, async (ctx) => { + const { q, sort, order, offset, limit, role } = ctx.req.valid('query'); + + const memberships = db + .select({ + userId: membershipsTable.userId, + }) + .from(membershipsTable) + .as('user_memberships'); + + const membershipCounts = db + .select({ + userId: memberships.userId, + count: count().as('count'), + }) + .from(memberships) + .groupBy(memberships.userId) + .as('membership_counts'); + + const orderColumn = getOrderColumn( + { + id: usersTable.id, + name: usersTable.name, + email: usersTable.email, + createdAt: usersTable.createdAt, + lastSeenAt: usersTable.lastSeenAt, + membershipCount: membershipCounts.count, + role: usersTable.role, + }, + sort, + usersTable.id, + order, + ); + + const filters = []; + if (q) { + filters.push(or(ilike(usersTable.name, `%${q}%`), ilike(usersTable.email, `%${q}%`))); + } + if (role) { + filters.push(eq(usersTable.role, role.toUpperCase() as User['role'])); + } + + const usersQuery = db + .select({ + user: usersTable, + counts: { + memberships: coalesce(membershipCounts.count, 0), + }, + }) + .from(usersTable) + .where(filters.length > 0 ? and(...filters) : undefined) + .orderBy(orderColumn) + .leftJoin(membershipCounts, eq(membershipCounts.userId, usersTable.id)); + + const [{ total }] = await db.select({ total: count() }).from(usersQuery.as('users')); + + const result = await usersQuery.limit(Number(limit)).offset(Number(offset)); + + const items = result.map(({ user, counts }) => transformDatabaseUserWithCount(user, counts.memberships)); + + return ctx.json({ success: true, data: { items, total } }, 200); + }) + /* + * Delete users + */ + .openapi(usersRoutesConfig.deleteUsers, async (ctx) => { + const { ids } = ctx.req.valid('query'); + const user = ctx.get('user'); + + // Convert the user ids to an array + const userIds = Array.isArray(ids) ? ids : [ids]; + + const errors: ErrorType[] = []; + + // Get the users + const targets = await db.select().from(usersTable).where(inArray(usersTable.id, userIds)); + + // Check if the users exist + for (const id of userIds) { + if (!targets.some((target) => target.id === id)) { + errors.push( + createError(ctx, 404, 'not_found', 'warn', 'USER', { + user: id, + }), + ); + } + } + + // Filter out users that the user doesn't have permission to delete + const allowedTargets = targets.filter((target) => { + const userId = target.id; + + if (user.role !== 'ADMIN' && user.id !== userId) { + errors.push( + createError(ctx, 403, 'delete_forbidden', 'warn', 'USER', { + user: userId, + }), + ); + return false; + } + + return true; + }); + + // If the user doesn't have permission to delete any of the users, return an error + if (allowedTargets.length === 0) { + return ctx.json({ success: false, errors: errors }, 200); + } + + // Delete the users + await db.delete(usersTable).where( + inArray( + usersTable.id, + allowedTargets.map((target) => target.id), + ), + ); + + // Send SSE events for the users that were deleted + for (const { id } of allowedTargets) { + // Invalidate the user's sessions if the user is deleting themselves + if (user.id === id) { + await auth.invalidateUserSessions(user.id); + removeSessionCookie(ctx); + } + + logEvent('User deleted', { user: id }); + } + + return ctx.json({ success: true, errors: errors }, 200); + }) + /* + * Get a user by id or slug + */ + .openapi(usersRoutesConfig.getUser, async (ctx) => { + const idOrSlug = ctx.req.param('idOrSlug'); + const user = ctx.get('user'); + + const [targetUser] = await db + .select() + .from(usersTable) + .where(or(eq(usersTable.id, idOrSlug), eq(usersTable.slug, idOrSlug))); + + if (!targetUser) { + return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { user: idOrSlug }); + } + + if (user.role !== 'ADMIN' && user.id !== targetUser.id) { + return errorResponse(ctx, 403, 'forbidden', 'warn', 'USER', { user: targetUser.id }); + } + + const [{ memberships }] = await db + .select({ + memberships: count(), + }) + .from(membershipsTable) + .where(eq(membershipsTable.userId, targetUser.id)); + + return ctx.json( + { + success: true, + data: transformDatabaseUserWithCount(targetUser, memberships), + }, + 200, + ); + }) + /* + * Update a user by id or slug + */ + .openapi(usersRoutesConfig.updateUser, async (ctx) => { + const { idOrSlug } = ctx.req.valid('param'); + + const user = ctx.get('user'); + const [targetUser] = await db + .select() + .from(usersTable) + .where(or(eq(usersTable.id, idOrSlug), eq(usersTable.slug, idOrSlug))); + + if (!targetUser) { + return errorResponse(ctx, 404, 'not_found', 'warn', 'USER', { user: idOrSlug }); + } + + if (user.role !== 'ADMIN' && user.id !== targetUser.id) { + return errorResponse(ctx, 403, 'forbidden', 'warn', 'USER', { user: idOrSlug }); + } + + const { email, bannerUrl, bio, firstName, lastName, language, newsletter, thumbnailUrl, slug, role } = ctx.req.valid('json'); + + if (slug && slug !== targetUser.slug) { + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'USER', { slug }); + } + } + + const [updatedUser] = await db + .update(usersTable) + .set({ + email, + bannerUrl, + bio, + firstName, + lastName, + language, + newsletter, + thumbnailUrl, + slug, + role, + name: [firstName, lastName].filter(Boolean).join(' ') || slug, + modifiedAt: new Date(), + modifiedBy: user.id, + }) + .where(eq(usersTable.id, targetUser.id)) + .returning(); + + const [{ memberships }] = await db + .select({ + memberships: count(), + }) + .from(membershipsTable) + .where(eq(membershipsTable.userId, updatedUser.id)); + + logEvent('User updated', { user: updatedUser.id }); + return ctx.json( + { + success: true, + data: transformDatabaseUserWithCount(updatedUser, memberships), + }, + 200, + ); + }); + +export default usersRoutes; diff --git a/backend/src/modules/users/routes.ts b/backend/src/modules/users/routes.ts new file mode 100644 index 000000000..800f04456 --- /dev/null +++ b/backend/src/modules/users/routes.ts @@ -0,0 +1,107 @@ +import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithPaginationSchema } from '../../lib/common-responses'; +import { idsQuerySchema, entityParamSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAuthenticated, isSystemAdmin } from '../../middlewares/guard'; +import { userSchema, usersQuerySchema, updateUserBodySchema } from './schema'; + +class UsersRoutesConfig { + public getUsers = createRouteConfig({ + method: 'get', + path: '/', + guard: [isAuthenticated, isSystemAdmin], + tags: ['users'], + summary: 'Get list of users', + description: 'Get a list of users on system level.', + request: { + query: usersQuerySchema, + }, + responses: { + 200: { + description: 'Users', + content: { + 'application/json': { + schema: successWithPaginationSchema(userSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteUsers = createRouteConfig({ + method: 'delete', + path: '/', + guard: [isAuthenticated, isSystemAdmin], + tags: ['users'], + summary: 'Delete users', + description: 'Delete users from system by list of ids.', + request: { + query: idsQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); + + public getUser = createRouteConfig({ + method: 'get', + path: '/{idOrSlug}', + guard: isAuthenticated, + tags: ['users'], + summary: 'Get user', + description: 'Get a user by id or slug.', + request: { + params: entityParamSchema, + }, + responses: { + 200: { + description: 'User', + content: { + 'application/json': { + schema: successWithDataSchema(userSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public updateUser = createRouteConfig({ + method: 'put', + path: '/{idOrSlug}', + guard: [isAuthenticated, isSystemAdmin], + tags: ['users'], + summary: 'Update user', + description: 'Update a user by id or slug.', + request: { + params: entityParamSchema, + body: { + content: { + 'application/json': { + schema: updateUserBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User', + content: { + 'application/json': { + schema: successWithDataSchema(userSchema), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new UsersRoutesConfig(); diff --git a/backend/src/modules/users/schema.ts b/backend/src/modules/users/schema.ts new file mode 100644 index 000000000..0926ee3cb --- /dev/null +++ b/backend/src/modules/users/schema.ts @@ -0,0 +1,53 @@ +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { z } from 'zod'; + +import { config } from 'config'; +import { usersTable } from '../../db/schema/users'; +import { imageUrlSchema, nameSchema, paginationQuerySchema, validSlugSchema } from '../../lib/common-schemas'; + +export const userSchema = createSelectSchema(usersTable, { + email: z.string().email(), + lastSeenAt: z.string().nullable(), + lastVisitAt: z.string().nullable(), + lastSignInAt: z.string().nullable(), + createdAt: z.string(), + modifiedAt: z.string().nullable(), +}) + .omit({ + hashedPassword: true, + }) + .setKey( + 'counts', + z.object({ + memberships: z.number(), + }), + ); + +export const usersQuerySchema = paginationQuerySchema.merge( + z.object({ + sort: z.enum(['id', 'name', 'email', 'role', 'createdAt', 'lastSeenAt', 'membershipCount']).default('createdAt').optional(), + role: z.enum(config.rolesByType.systemRoles).default('USER').optional(), + }), +); + +export const updateUserBodySchema = createInsertSchema(usersTable, { + email: z.string().email(), + firstName: nameSchema, + lastName: nameSchema, + slug: validSlugSchema, + thumbnailUrl: imageUrlSchema, + bannerUrl: imageUrlSchema, +}) + .pick({ + email: true, + bannerUrl: true, + bio: true, + firstName: true, + lastName: true, + language: true, + newsletter: true, + thumbnailUrl: true, + slug: true, + role: true, + }) + .partial(); diff --git a/backend/src/modules/workspaces/index.ts b/backend/src/modules/workspaces/index.ts new file mode 100644 index 000000000..cbcc5e55b --- /dev/null +++ b/backend/src/modules/workspaces/index.ts @@ -0,0 +1,171 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '../../db/db'; +import { membershipsTable } from '../../db/schema/memberships'; +import { workspacesTable } from '../../db/schema/workspaces'; + +import { type ErrorType, createError, errorResponse } from '../../lib/errors'; +import { sendSSEToUsers } from '../../lib/sse'; +import { logEvent } from '../../middlewares/logger/log-event'; +import { CustomHono } from '../../types/common'; +import { checkSlugAvailable } from '../general/helpers/check-slug'; +import { toMembershipInfo } from '../memberships/helpers/to-membership-info'; +import workspaceRoutesConfig from './routes'; +import { insertMembership } from '../memberships/helpers/insert-membership'; + +const app = new CustomHono(); + +// Workspace endpoints +const workspacesRoutes = app + /* + * Create workspace + */ + .openapi(workspaceRoutesConfig.createWorkspace, async (ctx) => { + const { name, slug, organizationId } = ctx.req.valid('json'); + const user = ctx.get('user'); + const memberships = ctx.get('memberships'); + + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'WORKSPACE', { slug }); + } + + const [workspace] = await db + .insert(workspacesTable) + .values({ + organizationId, + name, + slug, + }) + .returning(); + + logEvent('Workspace created', { workspace: workspace.id }); + + // Insert membership + const [createdMembership] = await insertMembership({ user, role: 'ADMIN', entity: workspace, memberships }); + + return ctx.json( + { + success: true, + data: { + ...workspace, + membership: toMembershipInfo(createdMembership), + }, + }, + 200, + ); + }) + /* + * Get workspace by id or slug + */ + .openapi(workspaceRoutesConfig.getWorkspace, async (ctx) => { + const workspace = ctx.get('workspace'); + const memberships = ctx.get('memberships'); + const membership = memberships.find((m) => m.workspaceId === workspace.id && m.type === 'WORKSPACE'); + + return ctx.json( + { + success: true, + data: { + ...workspace, + membership: toMembershipInfo(membership), + }, + }, + 200, + ); + }) + /* + * Update workspace + */ + .openapi(workspaceRoutesConfig.updateWorkspace, async (ctx) => { + const user = ctx.get('user'); + const workspace = ctx.get('workspace'); + + const { name, slug, thumbnailUrl } = ctx.req.valid('json'); + + if (slug && slug !== workspace.slug) { + const slugAvailable = await checkSlugAvailable(slug); + + if (!slugAvailable) { + return errorResponse(ctx, 409, 'slug_exists', 'warn', 'WORKSPACE', { slug }); + } + } + + const [updatedWorkspace] = await db + .update(workspacesTable) + .set({ + name, + slug, + thumbnailUrl, + organizationId: workspace.organizationId, + modifiedAt: new Date(), + modifiedBy: user.id, + }) + .where(eq(workspacesTable.id, workspace.id)) + .returning(); + + const memberships = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'WORKSPACE'), eq(membershipsTable.workspaceId, workspace.id))); + + if (memberships.length > 0) { + memberships.map((member) => + sendSSEToUsers([member.id], 'update_entity', { + ...updatedWorkspace, + membership: toMembershipInfo(memberships.find((m) => m.id === member.id)), + }), + ); + } + + logEvent('Workspace updated', { workspace: updatedWorkspace.id }); + + return ctx.json( + { + success: true, + data: { + ...updatedWorkspace, + membership: toMembershipInfo(memberships.find((m) => m.id === user.id)), + }, + }, + 200, + ); + }) + /* + * Delete workspaces + */ + .openapi(workspaceRoutesConfig.deleteWorkspaces, async (ctx) => { + // Extract allowed and disallowed ids + const allowedIds = ctx.get('allowedIds'); + const disallowedIds = ctx.get('disallowedIds'); + + // Map errors of workspaces user is not allowed to delete + const errors: ErrorType[] = disallowedIds.map((id) => createError(ctx, 404, 'not_found', 'warn', 'WORKSPACE', { workspace: id })); + + // Get members + const workspaceMembers = await db + .select({ id: membershipsTable.userId, workspaceId: membershipsTable.workspaceId }) + .from(membershipsTable) + .where(and(eq(membershipsTable.type, 'WORKSPACE'), inArray(membershipsTable.workspaceId, allowedIds))); + + // Delete the workspaces + await db.delete(workspacesTable).where(inArray(workspacesTable.id, allowedIds)); + + // Send SSE events for the workspaces that were deleted + for (const id of allowedIds) { + // Send the event to the user if they are a member of the workspace + if (workspaceMembers.length > 0) { + const membersId = workspaceMembers + .filter(({ workspaceId }) => workspaceId === id) + .map((member) => member.id) + .filter(Boolean) as string[]; + sendSSEToUsers(membersId, 'remove_entity', { id, entity: 'WORKSPACE' }); + } + + logEvent('Workspace deleted', { workspace: id }); + } + + return ctx.json({ success: true, errors: errors }, 200); + }); + +export default workspacesRoutes; diff --git a/backend/src/modules/workspaces/routes.ts b/backend/src/modules/workspaces/routes.ts new file mode 100644 index 000000000..48aea6409 --- /dev/null +++ b/backend/src/modules/workspaces/routes.ts @@ -0,0 +1,115 @@ +import { errorResponses, successWithDataSchema, successWithErrorsSchema } from '../../lib/common-responses'; +import { idsQuerySchema, entityParamSchema } from '../../lib/common-schemas'; +import { createRouteConfig } from '../../lib/route-config'; +import { isAllowedTo, isAuthenticated, splitByAllowance } from '../../middlewares/guard'; + +import { workspaceSchema, createWorkspaceBodySchema, updateWorkspaceBodySchema } from './schema'; + +class WorkspaceRoutesConfig { + public createWorkspace = createRouteConfig({ + method: 'post', + path: '/', + guard: [isAuthenticated, isAllowedTo('create', 'WORKSPACE')], + tags: ['workspaces'], + summary: 'Create new workspace', + description: 'Create personal workspace to organize projects and tasks.', + request: { + body: { + required: true, + content: { + 'application/json': { + schema: createWorkspaceBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'workspace was created', + content: { + 'application/json': { + schema: successWithDataSchema(workspaceSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public getWorkspace = createRouteConfig({ + method: 'get', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('read', 'WORKSPACE')], + tags: ['workspaces'], + summary: 'Get workspace', + description: 'Get workspace by id or slug.', + request: { + params: entityParamSchema, + }, + responses: { + 200: { + description: 'Workspace', + content: { + 'application/json': { + schema: successWithDataSchema(workspaceSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public updateWorkspace = createRouteConfig({ + method: 'put', + path: '/{idOrSlug}', + guard: [isAuthenticated, isAllowedTo('update', 'WORKSPACE')], + tags: ['workspaces'], + summary: 'Update workspace', + description: 'Update workspace by id or slug.', + request: { + params: entityParamSchema, + body: { + content: { + 'application/json': { + schema: updateWorkspaceBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Workspace updated', + content: { + 'application/json': { + schema: successWithDataSchema(workspaceSchema), + }, + }, + }, + ...errorResponses, + }, + }); + + public deleteWorkspaces = createRouteConfig({ + method: 'delete', + path: '/', + guard: [isAuthenticated, splitByAllowance('delete', 'workspace')], + tags: ['workspaces'], + summary: 'Delete workspaces', + description: 'Delete workspaces by ids.', + request: { + query: idsQuerySchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: successWithErrorsSchema(), + }, + }, + }, + ...errorResponses, + }, + }); +} +export default new WorkspaceRoutesConfig(); diff --git a/backend/src/modules/workspaces/schema.ts b/backend/src/modules/workspaces/schema.ts new file mode 100644 index 000000000..588a74577 --- /dev/null +++ b/backend/src/modules/workspaces/schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { workspacesTable } from '../../db/schema/workspaces'; +import { idSchema, imageUrlSchema, nameSchema, validSlugSchema } from '../../lib/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; + +export const workspaceSchema = z.object({ + ...createSelectSchema(workspacesTable).shape, + createdAt: z.string(), + modifiedAt: z.string().nullable(), + membership: membershipInfoSchema.nullable(), +}); + +export const createWorkspaceBodySchema = z.object({ + name: nameSchema, + slug: validSlugSchema, + organizationId: idSchema, +}); + +export const updateWorkspaceBodySchema = createInsertSchema(workspacesTable, { + name: nameSchema, + slug: validSlugSchema, + organizationId: idSchema, + thumbnailUrl: imageUrlSchema, +}) + .pick({ + slug: true, + name: true, + thumbnailUrl: true, + organizationId: true, + }) + .partial(); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 000000000..a7dd9b085 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,56 @@ +import defaultHook from './lib/default-hook'; +import docs from './lib/docs'; +import { errorResponse } from './lib/errors'; +import middlewares from './middlewares'; +import authRoutes from './modules/auth'; +import generalRoutes from './modules/general'; +import meRoutes from './modules/me'; +import membershipsRoutes from './modules/memberships'; +import organizationsRoutes from './modules/organizations'; +import projectsRoutes from './modules/projects'; +import requestsRoutes from './modules/requests'; +import usersRoutes from './modules/users'; +import workspacesRoutes from './modules/workspaces'; + +import { CustomHono } from './types/common'; + +// Set default hook to catch validation errors +const app = new CustomHono({ + defaultHook, +}); + +// Add global middleware +app.route('', middlewares); + +// Init OpenAPI docs +docs(app); + +// Not found handler +app.notFound((ctx) => { + // t('common:error.route_not_found.text') + return errorResponse(ctx, 404, 'route_not_found', 'warn', undefined, { path: ctx.req.path }); +}); + +// Error handler +app.onError((err, ctx) => { + // t('common:error.server_error.text') + return errorResponse(ctx, 500, 'server_error', 'error', undefined, {}, err); +}); + +// Add routes for each module +const routes = app + .route('/auth', authRoutes) + .route('/me', meRoutes) + .route('/users', usersRoutes) + .route('/organizations', organizationsRoutes) + .route('/', generalRoutes) + .route('/requests', requestsRoutes) + .route('/memberships', membershipsRoutes) + + // App-specific routes go here + .route('/workspaces', workspacesRoutes) + .route('/projects', projectsRoutes); + +export default app; + +export type AppType = typeof routes; diff --git a/backend/src/types/common.ts b/backend/src/types/common.ts new file mode 100644 index 000000000..5b0afc8e7 --- /dev/null +++ b/backend/src/types/common.ts @@ -0,0 +1,36 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { User } from 'lucia'; +import type { z } from 'zod'; + +import type { config } from 'config'; +import type { Schema } from 'hono'; +import type { MembershipModel } from '../db/schema/memberships'; +import type { OrganizationModel } from '../db/schema/organizations'; +import type { ProjectModel } from '../db/schema/projects'; +import type { WorkspaceModel } from '../db/schema/workspaces'; +import type { failWithErrorSchema } from '../lib/common-schemas'; + +export type Entity = (typeof config.entityTypes)[number]; + +export type ContextEntity = (typeof config.contextEntityTypes)[number]; + +export type OauthProviderOptions = (typeof config.oauthProviderOptions)[number]; + +export type NonEmptyArray = readonly [T, ...T[]]; + +export type ErrorResponse = z.infer; + +export type Env = { + Variables: { + user: User; + organization: OrganizationModel; + workspace: WorkspaceModel; + memberships: [MembershipModel]; + project: ProjectModel; + allowedIds: Array; + disallowedIds: Array; + }; +}; + +// biome-ignore lint/complexity/noBannedTypes: +export class CustomHono extends OpenAPIHono {} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 000000000..cf686a73e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + /* Bundler mode */ + "jsx": "react-jsx", + + "sourceMap": true, + "inlineSources": true, + + // Set `sourceRoot` to "/" to strip the build path prefix + // from generated source code references. + // This improves issue grouping in Sentry. + "sourceRoot": "/" + }, + "include": ["src", "drizzle.config.ts"] +} diff --git a/backend/tsup.config.ts b/backend/tsup.config.ts new file mode 100644 index 000000000..97d1d3d76 --- /dev/null +++ b/backend/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/db/migrate.ts', + 'src/db/migrate.electric.ts', + 'seed/template/user.ts', + 'seed/template/data.ts', + 'seed/app-specific/data.ts', + ], + splitting: false, + sourcemap: true, + clean: true, + format: ['cjs'], + minify: false, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..a4986766f --- /dev/null +++ b/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.7.2/schema.json", + "files": { + "ignore": ["node_modules", "dist", ".react-email", "generated", "wa-sqlite-async.mjs", "drizzle", "drizzle-electric"] + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useExhaustiveDependencies": "off" + }, + "style": { + "useEnumInitializers": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "lineWidth": 150 + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "json": { + "parser": { + "allowComments": true + } + } +} diff --git a/blog/.gitignore b/blog/.gitignore new file mode 100644 index 000000000..0d45fead1 --- /dev/null +++ b/blog/.gitignore @@ -0,0 +1,34 @@ +# prod +dist/ + +# dev +.hono/ +.wrangler/ +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ + +# env +.env +.env.production +dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/blog/app/client.ts b/blog/app/client.ts new file mode 100644 index 000000000..e0e95697d --- /dev/null +++ b/blog/app/client.ts @@ -0,0 +1,12 @@ +import { createClient } from 'honox/client'; + +createClient({ + hydrate: async (elem, root) => { + const { hydrateRoot } = await import('react-dom/client'); + hydrateRoot(root, elem); + }, + createElement: async (type: any, props: any) => { + const { createElement } = await import('react'); + return createElement(type, props); + }, +}); diff --git a/blog/app/constants/author.ts b/blog/app/constants/author.ts new file mode 100644 index 000000000..d7738a7bf --- /dev/null +++ b/blog/app/constants/author.ts @@ -0,0 +1,13 @@ +type Author = { + name: string; + about: string; + icon: string; +}; + +export const authors: { [key: string]: Author } = { + yossydev: { + name: 'yossydev', + about: 'web developer', + icon: '/static/author/yossydev.jpg', + }, +} as const; diff --git a/blog/app/global.d.ts b/blog/app/global.d.ts new file mode 100644 index 000000000..ea527c07d --- /dev/null +++ b/blog/app/global.d.ts @@ -0,0 +1,9 @@ +import {} from 'hono'; + +import '@hono/react-renderer'; + +declare module '@hono/react-renderer' { + interface Props { + title?: string; + } +} diff --git a/blog/app/index.css b/blog/app/index.css new file mode 100644 index 000000000..b4d06723f --- /dev/null +++ b/blog/app/index.css @@ -0,0 +1,369 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 10%; + --card: 0 0% 100%; + --card-foreground: 240 10% 10%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% %; + --primary: 240 6% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 240 6% 20%; + --muted: 240 5% 77.2%; + --muted-foreground: 240 4% 40%; + --accent: 240 5% 95%; + --accent-foreground: 240 6% 10%; + --destructive: 0 85% 40%; + --destructive-foreground: 0 0% 98%; + --border: 240 6% 92%; + --input: 240 6% 92%; + --ring: 240 6% 10%; + --radius: 0.5rem; + --success: 120 100% 27%; + } + + .dark { + --background: 240 10% 9%; + --foreground: 0 0% 95%; + --card: 240 10% 14%; + --card-foreground: 0 0% 95%; + --popover: 240 10% 6%; + --popover-foreground: 0 0% 95%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 16%; + --secondary: 240 3.7% 15%; + --secondary-foreground: 0 0% 95%; + --muted: 240 3.7% 25%; + --muted-foreground: 240 5% 84.9%; + --accent: 240 3.7% 25%; + --accent-foreground: 0 0% 95%; + --destructive: 0 62.8% 50%; + --destructive-foreground: 0 0% 95%; + --border: 240 3.7% 20%; + --input: 240 3.7% 25%; + --ring: 240 4.9% 83.9%; + } + + .theme-rose.light { + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + } + + .theme-rose.dark { + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 3.7% 15%; + --secondary-foreground: 0 0% 98%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 0 0% 98%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground h-svh; + } + + #root { + @apply flex flex-col h-full; + } +} + +@layer components { + .rich-gradient:after { + content: ""; + display: block; + position: absolute; + top: 0; + opacity: 1; + width: 100%; + height: 100%; + z-index: -3; + background-image: linear-gradient( + to bottom left, + rgba(0, 0, 0, 0), + rgba(255, 199, 147, 0.87) + ), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgb(57, 160, 251)), + linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgb(195, 120, 241)), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgb(2, 155, 129)); + } + + .dark .rich-gradient:after { + opacity: 0.8; + background-image: linear-gradient( + to bottom left, + rgba(0, 0, 0, 0), + rgba(60, 0, 59, 1) + ), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgba(15, 6, 86, 1)), + linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(184, 144, 0, 1)), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgba(11, 144, 122, 1)); + } + + .rich-gradient:before { + content: ""; + display: block; + position: absolute; + top: 0; + opacity: 1; + width: 100%; + height: 100%; + z-index: -2; + background-image: radial-gradient( + circle 90vh at 40% 40%, + hsla(var(--background)), + rgba(255, 255, 255, 0.5) + ); + } + + .dark .rich-gradient:before { + opacity: 0.8; + background-image: radial-gradient( + circle 90vh at 40% 40%, + hsla(var(--background)), + rgba(255, 255, 255, 0) + ); + } + + .rich-gradient.dark-gradient:after { + opacity: 1; + background: rgba(0, 0, 0, 1); + background-image: linear-gradient( + to bottom left, + rgba(0, 0, 0, 0), + rgba(255, 255, 255, 0.2) + ), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgba(49, 49, 230, 0.3)), + linear-gradient( + to bottom right, + rgba(0, 0, 0, 0), + rgba(156, 39, 228, 0.2) + ), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgba(254, 185, 24, 0.3)); + } + + .dark .dark-gradient:after { + opacity: 0.2; + } + + .rich-gradient.dark-gradient:before { + background-image: radial-gradient( + circle 120vh at 40% 120%, + rgba(59, 17, 37, 0.6), + rgba(255, 255, 255, 0) + ); + } +} + +.outline-glow-button:before { + content: ""; + display: block; + position: absolute; + top: -1px; + left: -1px; + opacity: 0.5; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -2; + border-radius: 100px; + background: linear-gradient( + 45deg, + rgba(151, 53, 255, 1) -10%, + rgba(251, 204, 38, 1) 60%, + rgba(210, 35, 82, 1) 100% + ); +} + +.outline-glow-button:after { + content: ""; + display: block; + position: absolute; + top: -1px; + left: -1px; + filter: blur(6px); + opacity: 0.4; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -2; + border-radius: 100px; + background: linear-gradient( + 45deg, + rgba(151, 53, 255, 1) -10%, + rgba(251, 204, 38, 1) 60%, + rgba(210, 35, 82, 1) 100% + ); +} + +.outline-glow-button:hover::before { + opacity: 1; +} + +.outline-glow-button:hover::after { + opacity: 0.8; +} + +.outline-glow-button:active::after { + position: absolute; + top: 0px; + left: 0px; + filter: none; + opacity: 1; + width: 100%; + height: 100%; + z-index: -1; + border-radius: 100px; + background: hsla(var(--background)); +} + +.gradient-button:after, +.gradient-button:before { + display: block; + left: 0; + top: 0; + position: absolute; + width: 100%; + height: 100%; +} + +.gradient-button:before { + z-index: -2; + filter: saturate(5); +} + +.gradient-button:after { + background: linear-gradient( + to right, + rgb(100, 100, 100), + rgba(204, 204, 204, 0.8) 40%, + rgba(204, 204, 204, 0.8) 55%, + rgb(34, 34, 34) + ); + opacity: 0.3; + z-index: -1; +} + +.fill-grid { + block-size: 100%; +} + +.main-container { + display: grid; + grid-template-rows: auto 1fr auto; + grid-template-columns: 100%; + min-height: 100vh; +} + +.markdown { + @apply text-gray-900 leading-normal break-words; +} + +.markdown > * + * { + @apply mt-0 mb-4; +} + +.markdown li + li { + @apply mt-1; +} + +.markdown li > p + p { + @apply mt-6; +} + +.markdown strong { + @apply font-semibold; +} + +.markdown a { + @apply text-blue-600 font-semibold; +} + +.markdown strong a { + @apply font-bold; +} + +.markdown h1 { + @apply leading-tight border-b text-3xl font-semibold mb-4 mt-6 pb-2; +} + +.markdown h2 { + @apply leading-tight border-b text-2xl font-semibold mb-4 mt-6 pb-2; +} + +.markdown h3 { + @apply leading-snug text-lg font-semibold mb-4 mt-6; +} + +.markdown h4 { + @apply leading-none text-base font-semibold mb-4 mt-6; +} + +.markdown h5 { + @apply leading-tight text-sm font-semibold mb-4 mt-6; +} + +.markdown h6 { + @apply leading-tight text-sm font-semibold text-gray-600 mb-4 mt-6; +} + +.markdown blockquote { + @apply text-base border-l-4 border-gray-200 pl-4 pr-4 text-gray-600; +} + +.markdown code { + @apply font-mono text-sm inline bg-gray-200 rounded px-1 py-[2px]; +} + +.markdown pre { + @apply bg-gray-100 rounded p-4; +} + +.markdown pre code { + @apply block bg-transparent p-0 overflow-visible rounded-none; +} + +.markdown ul { + @apply text-base pl-8 list-disc; +} + +.markdown ol { + @apply text-base pl-8 list-decimal; +} + +.markdown kbd { + @apply text-xs inline-block rounded border px-1 py-5 align-middle font-normal font-mono shadow; +} + +.markdown table { + @apply text-base border-gray-600; +} + +.markdown th { + @apply border py-1 px-3; +} + +.markdown td { + @apply border py-1 px-3; +} + +/* Override pygments style background color. */ +.markdown .highlight pre { + @apply bg-gray-100 !important; +} + +.date::after { + content: ":" / ""; +} diff --git a/blog/app/islands/Nav.tsx b/blog/app/islands/Nav.tsx new file mode 100644 index 000000000..174b67894 --- /dev/null +++ b/blog/app/islands/Nav.tsx @@ -0,0 +1,14 @@ +import MarketingPage from '~/modules/marketing/page'; +import type { FC, PropsWithChildren } from 'react'; + +type Props = PropsWithChildren; + +const Layout: FC = ({ children }) => { + return ( + +
{children}
+
+ ); +}; + +export default Layout; diff --git a/blog/app/islands/counter.tsx b/blog/app/islands/counter.tsx new file mode 100644 index 000000000..0bb543841 --- /dev/null +++ b/blog/app/islands/counter.tsx @@ -0,0 +1,11 @@ +import { useState } from 'hono/jsx' + +export default function Counter() { + const [count, setCount] = useState(0) + return ( +
+

{count}

+ +
+ ) +} diff --git a/blog/app/lib/utils.ts b/blog/app/lib/utils.ts new file mode 100644 index 000000000..9ad0df426 --- /dev/null +++ b/blog/app/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/blog/app/routes/_404.tsx b/blog/app/routes/_404.tsx new file mode 100644 index 000000000..77ee76c1c --- /dev/null +++ b/blog/app/routes/_404.tsx @@ -0,0 +1,8 @@ +import type { NotFoundHandler } from 'hono' + +const handler: NotFoundHandler = (c) => { + c.status(404) + return c.render('404 Not Found') +} + +export default handler diff --git a/blog/app/routes/_error.tsx b/blog/app/routes/_error.tsx new file mode 100644 index 000000000..2c8e934e3 --- /dev/null +++ b/blog/app/routes/_error.tsx @@ -0,0 +1,12 @@ +import type { ErrorHandler } from 'hono' + +const handler: ErrorHandler = (e, c) => { + if ('getResponse' in e) { + return e.getResponse() + } + console.error(e.message) + c.status(500) + return c.render('Internal Server Error') +} + +export default handler diff --git a/blog/app/routes/_renderer.tsx b/blog/app/routes/_renderer.tsx new file mode 100644 index 000000000..f31bc884a --- /dev/null +++ b/blog/app/routes/_renderer.tsx @@ -0,0 +1,38 @@ +import Layout from '@/islands/Nav'; +import { reactRenderer } from '@hono/react-renderer'; +import { useRequestContext } from '@hono/react-renderer'; +import { FC, PropsWithChildren } from 'react'; + +const HasIslands: FC = ({ children }) => { + const IMPORTING_ISLANDS_ID = '__importing_islands' as const; + const c = useRequestContext(); + return <>{c.get(IMPORTING_ISLANDS_ID) ? children : <>}; +}; + +export default reactRenderer(({ children, title }) => { + return ( + + + + + {import.meta.env.PROD ? ( + <> + + + + + +
+ + + + \ No newline at end of file diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 000000000..0cfaa626b --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,40 @@ +[build] + base = "." + publish = "./frontend/dist/" + command = "pnpm run build:fe" + environment = { NODE_ENV = 'production', NODE_VERSION = 20 } + +# The following redirect is intended for SPAs that handle routing internally. +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +# Production context: all deploys from the Production branch +# set in your site’s Branches settings in the UI will inherit +# these settings. You can define environment variables +# here but we recommend using the Netlify UI for sensitive +# values to keep them out of your source repository. +[context.production] + environment = { NODE_ENV = 'production', NODE_VERSION = 20 } + +# Set security headers and make sure to replace *.cellajs.com with your domain name(s) +# [[headers]] +# for = "/*" +# [headers.values] +# X-Content-Type-Options = "nosniff" +# Content-Security-Policy = ''' +# default-src 'self'; +# script-src 'self' *.cellajs.com *.vimeo.com *.googleapis.com; +# connect-src 'self' blob: *.cellajs.com; +# img-src 'self' blob: https: data:; +# media-src 'self' blob: data: https://i.ytimg.com; +# frame-src 'self' *.youtube.com *.vimeo.com; +# style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; +# font-src 'self' data: https://fonts.gstatic.com; +# ''' +# Referrer-Policy = "same-origin" +# Strict-Transport-Security = "max-age=15768000" +# X-XSS-Protection = "1; mode=block" +# X-Frame-Options= "SAMEORIGIN" +# Permissions-Policy = "camera=(), microphone=(), geolocation=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), midi=()" diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..ff735981d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,153 @@ +{ + "name": "frontend", + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=20.14.0" + }, + "scripts": { + "prepare:electric": "pnpm proxy & (NODE_ENV=production pnpm generate:electric && tsx prepare-files.ts)", + "prepare:electric:dev": "electric-sql generate && tsx prepare-files.ts", + "start": "pnpm run preview", + "dev": "NODE_ENV=development vite --mode development", + "build": "pnpm run prepare:electric && tsc && vite build", + "check:types": "tsc", + "build:dev": "tsc && NODE_ENV=development vite build --mode development", + "preview": "vite preview --port 3000", + "generate:electric": "electric-sql generate --service https://electric-sync.cellajs.com --proxy postgresql://postgres:proxy_password@0.0.0.0:65432/postgres", + "proxy": "electric-sql proxy-tunnel --service https://electric-sync.cellajs.com --local-port 65432" + }, + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.12", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.3.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@github/mini-throttle": "^2.1.1", + "@hookform/resolvers": "^3.6.0", + "@paddle/paddle-js": "^1.2.0", + "@prisma/client": "5.15.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.1", + "@sentry/react": "^8.11.0", + "@tailwindcss/typography": "^0.5.13", + "@tanstack/react-query": "^5.45.1", + "@tanstack/react-query-devtools": "^5.45.1", + "@tanstack/react-router": "1.40.0", + "@tanstack/router-devtools": "1.40.0", + "@uiw/react-md-editor": "^4.0.4", + "@uppy/audio": "^1.1.9", + "@uppy/core": "^3.13.0", + "@uppy/dashboard": "^3.9.0", + "@uppy/drag-drop": "^3.1.0", + "@uppy/file-input": "^3.1.2", + "@uppy/image-editor": "^2.4.6", + "@uppy/progress-bar": "^3.1.1", + "@uppy/react": "^3.4.0", + "@uppy/screen-capture": "^3.2.0", + "@uppy/status-bar": "^3.3.3", + "@uppy/tus": "^3.5.5", + "@uppy/webcam": "^3.4.2", + "@vis.gl/react-google-maps": "^1.1.0", + "backend": "workspace:*", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "config": "workspace:*", + "dayjs": "^1.11.11", + "electric-sql": "^0.12.0", + "embla-carousel-autoplay": "^8.1.5", + "embla-carousel-react": "^8.1.5", + "emblor": "^1.4.0", + "framer-motion": "^11.2.11", + "gleap": "^13.7.3", + "hono": "4.4.5", + "i18next": "^23.11.5", + "i18next-browser-languagedetector": "^7.2.1", + "i18next-http-backend": "^2.5.2", + "immer": "^10.1.1", + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2", + "locales": "workspace:*", + "lucide-react": "^0.358.0", + "nanoid": "^5.0.7", + "react": "^18.3.1", + "react-confetti-explosion": "^2.1.2", + "react-data-grid": "7.0.0-beta.43", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.52.0", + "react-i18next": "^14.1.2", + "react-intersection-observer": "^9.10.3", + "react-lazy-with-preload": "^2.2.1", + "react-resizable-panels": "^2.0.19", + "react-sticky-box": "^2.0.5", + "slugify": "1.6.6", + "sonner": "^1.5.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "use-count-up": "^3.0.1", + "vaul": "^0.9.1", + "wa-sqlite": "^1.0.0", + "zod": "^3.23.8", + "zustand": "^4.5.2", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@electric-sql/debug-toolbar": "^2.0.1", + "@faker-js/faker": "^8.4.1", + "@redux-devtools/extension": "^3.3.0", + "@rollup/plugin-terser": "^0.4.4", + "@sentry/vite-plugin": "^2.20.0", + "@types/lodash.clonedeep": "^4.5.9", + "@types/node": "^20.14.8", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/zxcvbn": "^4.4.4", + "@vitejs/plugin-basic-ssl": "^1.1.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "postcss-import": "^16.1.0", + "postcss-preset-env": "^9.5.14", + "postgres": "^3.4.4", + "prisma": "^5.15.1", + "rollup-plugin-visualizer": "^5.12.0", + "tailwindcss": "^3.4.4", + "tsx": "^4.15.7", + "typescript": "^5.5.2", + "vite": "5.2.6", + "vite-plugin-html": "^3.2.2", + "vite-plugin-pwa": "^0.20.0", + "vite-plugin-static-copy": "^1.0.5", + "workbox-window": "^7.1.0" + }, + "browserslist": [">0.2%", "not dead", "not op_mini all"], + "postcss": { + "plugins": { + "postcss-import": {}, + "tailwindcss/nesting": {}, + "tailwindcss": {}, + "autoprefixer": {} + } + } +} diff --git a/frontend/prepare-files.ts b/frontend/prepare-files.ts new file mode 100644 index 000000000..fe0ae776d --- /dev/null +++ b/frontend/prepare-files.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; + +// get .ts files from src/generated and add // @ts-nocheck to the top of each file +const path = 'src/generated'; +const files = fs.readdirSync(path, { + recursive: true, + encoding: 'utf8', +}); + +for (const file of files) { + if (file.endsWith('.ts')) { + const filePath = `${path}/${file}`; + const content = fs.readFileSync(filePath, 'utf8'); + fs.writeFileSync(filePath, `// @ts-nocheck\n${content}`); + } +} + +console.info('Files prepared'); diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 000000000..7023d9105 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 000000000..14267e903 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/frontend/public/static/email/arrow.png b/frontend/public/static/email/arrow.png new file mode 100644 index 000000000..018f64d2c Binary files /dev/null and b/frontend/public/static/email/arrow.png differ diff --git a/frontend/public/static/email/logo.png b/frontend/public/static/email/logo.png new file mode 100644 index 000000000..e91c750cd Binary files /dev/null and b/frontend/public/static/email/logo.png differ diff --git a/frontend/public/static/email/org.png b/frontend/public/static/email/org.png new file mode 100644 index 000000000..d3de7d938 Binary files /dev/null and b/frontend/public/static/email/org.png differ diff --git a/frontend/public/static/email/user.png b/frontend/public/static/email/user.png new file mode 100644 index 000000000..fd2aa8677 Binary files /dev/null and b/frontend/public/static/email/user.png differ diff --git a/frontend/public/static/features/authentication.svg b/frontend/public/static/features/authentication.svg new file mode 100644 index 000000000..d0cda2f42 --- /dev/null +++ b/frontend/public/static/features/authentication.svg @@ -0,0 +1,4 @@ + + Authentication + + \ No newline at end of file diff --git a/frontend/public/static/features/drizzle.svg b/frontend/public/static/features/drizzle.svg new file mode 100644 index 000000000..3ba9aafde --- /dev/null +++ b/frontend/public/static/features/drizzle.svg @@ -0,0 +1,4 @@ + + Database with Drizzle + + \ No newline at end of file diff --git a/frontend/public/static/features/electric.svg b/frontend/public/static/features/electric.svg new file mode 100644 index 000000000..182664d29 --- /dev/null +++ b/frontend/public/static/features/electric.svg @@ -0,0 +1,5 @@ + + Electric + + + \ No newline at end of file diff --git a/frontend/public/static/features/hono.svg b/frontend/public/static/features/hono.svg new file mode 100644 index 000000000..9c962edd3 --- /dev/null +++ b/frontend/public/static/features/hono.svg @@ -0,0 +1,4 @@ + + Hono + + \ No newline at end of file diff --git a/frontend/public/static/features/lucia.svg b/frontend/public/static/features/lucia.svg new file mode 100644 index 000000000..62aa0f4ca --- /dev/null +++ b/frontend/public/static/features/lucia.svg @@ -0,0 +1,4 @@ + + Lucia + + diff --git a/frontend/public/static/features/openapi.svg b/frontend/public/static/features/openapi.svg new file mode 100644 index 000000000..68242bda8 --- /dev/null +++ b/frontend/public/static/features/openapi.svg @@ -0,0 +1,13 @@ + + API Docs + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/static/features/plus.svg b/frontend/public/static/features/plus.svg new file mode 100644 index 000000000..28c4a9f67 --- /dev/null +++ b/frontend/public/static/features/plus.svg @@ -0,0 +1,5 @@ + + More tools + {' '} + + \ No newline at end of file diff --git a/frontend/public/static/features/react.svg b/frontend/public/static/features/react.svg new file mode 100644 index 000000000..f0faaa222 --- /dev/null +++ b/frontend/public/static/features/react.svg @@ -0,0 +1,4 @@ + + React + + \ No newline at end of file diff --git a/frontend/public/static/features/tailwind.svg b/frontend/public/static/features/tailwind.svg new file mode 100644 index 000000000..fe19b7562 --- /dev/null +++ b/frontend/public/static/features/tailwind.svg @@ -0,0 +1,4 @@ + + Tailwind and Shadcn + + \ No newline at end of file diff --git a/frontend/public/static/features/tanstack.svg b/frontend/public/static/features/tanstack.svg new file mode 100644 index 000000000..5a0d65e4b --- /dev/null +++ b/frontend/public/static/features/tanstack.svg @@ -0,0 +1,4 @@ + + Tanstack Router, Query and Table + + \ No newline at end of file diff --git a/frontend/public/static/features/vite.svg b/frontend/public/static/features/vite.svg new file mode 100644 index 000000000..f1978b101 --- /dev/null +++ b/frontend/public/static/features/vite.svg @@ -0,0 +1,4 @@ + + Vite and Turborepo + + \ No newline at end of file diff --git a/frontend/public/static/flags/ad.svg b/frontend/public/static/flags/ad.svg new file mode 100644 index 000000000..8d3096dc9 --- /dev/null +++ b/frontend/public/static/flags/ad.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/ae.svg b/frontend/public/static/flags/ae.svg new file mode 100644 index 000000000..64bc2bd6d --- /dev/null +++ b/frontend/public/static/flags/ae.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/af.svg b/frontend/public/static/flags/af.svg new file mode 100644 index 000000000..c919e0a42 --- /dev/null +++ b/frontend/public/static/flags/af.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ag.svg b/frontend/public/static/flags/ag.svg new file mode 100644 index 000000000..54d96e055 --- /dev/null +++ b/frontend/public/static/flags/ag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ai.svg b/frontend/public/static/flags/ai.svg new file mode 100644 index 000000000..66e974bd3 --- /dev/null +++ b/frontend/public/static/flags/ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/al.svg b/frontend/public/static/flags/al.svg new file mode 100644 index 000000000..393d18e75 --- /dev/null +++ b/frontend/public/static/flags/al.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/am.svg b/frontend/public/static/flags/am.svg new file mode 100644 index 000000000..533bcd83c --- /dev/null +++ b/frontend/public/static/flags/am.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ao.svg b/frontend/public/static/flags/ao.svg new file mode 100644 index 000000000..d8b0ed43c --- /dev/null +++ b/frontend/public/static/flags/ao.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/aq.svg b/frontend/public/static/flags/aq.svg new file mode 100644 index 000000000..58898daf2 --- /dev/null +++ b/frontend/public/static/flags/aq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ar.svg b/frontend/public/static/flags/ar.svg new file mode 100644 index 000000000..e71880e6d --- /dev/null +++ b/frontend/public/static/flags/ar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/as.svg b/frontend/public/static/flags/as.svg new file mode 100644 index 000000000..739866098 --- /dev/null +++ b/frontend/public/static/flags/as.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/at.svg b/frontend/public/static/flags/at.svg new file mode 100644 index 000000000..295e2ad14 --- /dev/null +++ b/frontend/public/static/flags/at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/au.svg b/frontend/public/static/flags/au.svg new file mode 100644 index 000000000..bab7eb4d0 --- /dev/null +++ b/frontend/public/static/flags/au.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/aw.svg b/frontend/public/static/flags/aw.svg new file mode 100644 index 000000000..d6cb10f29 --- /dev/null +++ b/frontend/public/static/flags/aw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ax.svg b/frontend/public/static/flags/ax.svg new file mode 100644 index 000000000..f5d71f588 --- /dev/null +++ b/frontend/public/static/flags/ax.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/az.svg b/frontend/public/static/flags/az.svg new file mode 100644 index 000000000..013aa6cda --- /dev/null +++ b/frontend/public/static/flags/az.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ba.svg b/frontend/public/static/flags/ba.svg new file mode 100644 index 000000000..45845f0df --- /dev/null +++ b/frontend/public/static/flags/ba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bb.svg b/frontend/public/static/flags/bb.svg new file mode 100644 index 000000000..e07f5205a --- /dev/null +++ b/frontend/public/static/flags/bb.svg @@ -0,0 +1,7 @@ + +Flag of Barbados + + + + + diff --git a/frontend/public/static/flags/bd.svg b/frontend/public/static/flags/bd.svg new file mode 100644 index 000000000..816914902 --- /dev/null +++ b/frontend/public/static/flags/bd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/be.svg b/frontend/public/static/flags/be.svg new file mode 100644 index 000000000..ce3cd3e45 --- /dev/null +++ b/frontend/public/static/flags/be.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bf.svg b/frontend/public/static/flags/bf.svg new file mode 100644 index 000000000..c38e95469 --- /dev/null +++ b/frontend/public/static/flags/bf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bg.svg b/frontend/public/static/flags/bg.svg new file mode 100644 index 000000000..4b1e0626f --- /dev/null +++ b/frontend/public/static/flags/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bh.svg b/frontend/public/static/flags/bh.svg new file mode 100644 index 000000000..c539a5528 --- /dev/null +++ b/frontend/public/static/flags/bh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bi.svg b/frontend/public/static/flags/bi.svg new file mode 100644 index 000000000..dcafa5a31 --- /dev/null +++ b/frontend/public/static/flags/bi.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/bj.svg b/frontend/public/static/flags/bj.svg new file mode 100644 index 000000000..a4e3d8798 --- /dev/null +++ b/frontend/public/static/flags/bj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bl.svg b/frontend/public/static/flags/bl.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/bl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bm.svg b/frontend/public/static/flags/bm.svg new file mode 100644 index 000000000..31a298aee --- /dev/null +++ b/frontend/public/static/flags/bm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bn.svg b/frontend/public/static/flags/bn.svg new file mode 100644 index 000000000..276ff9e78 --- /dev/null +++ b/frontend/public/static/flags/bn.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/bo.svg b/frontend/public/static/flags/bo.svg new file mode 100644 index 000000000..bb5727940 --- /dev/null +++ b/frontend/public/static/flags/bo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bq.svg b/frontend/public/static/flags/bq.svg new file mode 100644 index 000000000..86c0c376a --- /dev/null +++ b/frontend/public/static/flags/bq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/br.svg b/frontend/public/static/flags/br.svg new file mode 100644 index 000000000..a07bcf7eb --- /dev/null +++ b/frontend/public/static/flags/br.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bs.svg b/frontend/public/static/flags/bs.svg new file mode 100644 index 000000000..7f98ff84e --- /dev/null +++ b/frontend/public/static/flags/bs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bt.svg b/frontend/public/static/flags/bt.svg new file mode 100644 index 000000000..7a23e1166 --- /dev/null +++ b/frontend/public/static/flags/bt.svg @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/bv.svg b/frontend/public/static/flags/bv.svg new file mode 100644 index 000000000..69120a68b --- /dev/null +++ b/frontend/public/static/flags/bv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bw.svg b/frontend/public/static/flags/bw.svg new file mode 100644 index 000000000..2fe792ea8 --- /dev/null +++ b/frontend/public/static/flags/bw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/by.svg b/frontend/public/static/flags/by.svg new file mode 100644 index 000000000..574980fab --- /dev/null +++ b/frontend/public/static/flags/by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/bz.svg b/frontend/public/static/flags/bz.svg new file mode 100644 index 000000000..3cbb466a7 --- /dev/null +++ b/frontend/public/static/flags/bz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ca.svg b/frontend/public/static/flags/ca.svg new file mode 100644 index 000000000..f03177dd9 --- /dev/null +++ b/frontend/public/static/flags/ca.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cc.svg b/frontend/public/static/flags/cc.svg new file mode 100644 index 000000000..b7e3f565a --- /dev/null +++ b/frontend/public/static/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cd.svg b/frontend/public/static/flags/cd.svg new file mode 100644 index 000000000..862681eec --- /dev/null +++ b/frontend/public/static/flags/cd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cf.svg b/frontend/public/static/flags/cf.svg new file mode 100644 index 000000000..24e8e5799 --- /dev/null +++ b/frontend/public/static/flags/cf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cg.svg b/frontend/public/static/flags/cg.svg new file mode 100644 index 000000000..cbfd12ecc --- /dev/null +++ b/frontend/public/static/flags/cg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/ch.svg b/frontend/public/static/flags/ch.svg new file mode 100644 index 000000000..dd0ff636a --- /dev/null +++ b/frontend/public/static/flags/ch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ci.svg b/frontend/public/static/flags/ci.svg new file mode 100644 index 000000000..7563a18b3 --- /dev/null +++ b/frontend/public/static/flags/ci.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ck.svg b/frontend/public/static/flags/ck.svg new file mode 100644 index 000000000..c08f95d06 --- /dev/null +++ b/frontend/public/static/flags/ck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cl.svg b/frontend/public/static/flags/cl.svg new file mode 100644 index 000000000..b33865655 --- /dev/null +++ b/frontend/public/static/flags/cl.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cm.svg b/frontend/public/static/flags/cm.svg new file mode 100644 index 000000000..901fecf36 --- /dev/null +++ b/frontend/public/static/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cn.svg b/frontend/public/static/flags/cn.svg new file mode 100644 index 000000000..69a708173 --- /dev/null +++ b/frontend/public/static/flags/cn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/co.svg b/frontend/public/static/flags/co.svg new file mode 100644 index 000000000..6cb2061f4 --- /dev/null +++ b/frontend/public/static/flags/co.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/cr.svg b/frontend/public/static/flags/cr.svg new file mode 100644 index 000000000..a5f292b28 --- /dev/null +++ b/frontend/public/static/flags/cr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cu.svg b/frontend/public/static/flags/cu.svg new file mode 100644 index 000000000..09558fa36 --- /dev/null +++ b/frontend/public/static/flags/cu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/cv.svg b/frontend/public/static/flags/cv.svg new file mode 100644 index 000000000..1f61248dc --- /dev/null +++ b/frontend/public/static/flags/cv.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cw.svg b/frontend/public/static/flags/cw.svg new file mode 100644 index 000000000..f2c985336 --- /dev/null +++ b/frontend/public/static/flags/cw.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cx.svg b/frontend/public/static/flags/cx.svg new file mode 100644 index 000000000..e3ec56021 --- /dev/null +++ b/frontend/public/static/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/cy.svg b/frontend/public/static/flags/cy.svg new file mode 100644 index 000000000..54489e74c --- /dev/null +++ b/frontend/public/static/flags/cy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/cz.svg b/frontend/public/static/flags/cz.svg new file mode 100644 index 000000000..db03aba30 --- /dev/null +++ b/frontend/public/static/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/de.svg b/frontend/public/static/flags/de.svg new file mode 100644 index 000000000..aa9fbd31b --- /dev/null +++ b/frontend/public/static/flags/de.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/dj.svg b/frontend/public/static/flags/dj.svg new file mode 100644 index 000000000..1d49f3400 --- /dev/null +++ b/frontend/public/static/flags/dj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/dk.svg b/frontend/public/static/flags/dk.svg new file mode 100644 index 000000000..ae58c504d --- /dev/null +++ b/frontend/public/static/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/dm.svg b/frontend/public/static/flags/dm.svg new file mode 100644 index 000000000..028b33bfa --- /dev/null +++ b/frontend/public/static/flags/dm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/do.svg b/frontend/public/static/flags/do.svg new file mode 100644 index 000000000..fa272ed58 --- /dev/null +++ b/frontend/public/static/flags/do.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/dz.svg b/frontend/public/static/flags/dz.svg new file mode 100644 index 000000000..e1165a7e9 --- /dev/null +++ b/frontend/public/static/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/ec.svg b/frontend/public/static/flags/ec.svg new file mode 100644 index 000000000..b673e6e1e --- /dev/null +++ b/frontend/public/static/flags/ec.svg @@ -0,0 +1,519 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/ee.svg b/frontend/public/static/flags/ee.svg new file mode 100644 index 000000000..1bddd1ad4 --- /dev/null +++ b/frontend/public/static/flags/ee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/eg.svg b/frontend/public/static/flags/eg.svg new file mode 100644 index 000000000..e53e54a2c --- /dev/null +++ b/frontend/public/static/flags/eg.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/eh.svg b/frontend/public/static/flags/eh.svg new file mode 100644 index 000000000..a5b077200 --- /dev/null +++ b/frontend/public/static/flags/eh.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/static/flags/er.svg b/frontend/public/static/flags/er.svg new file mode 100644 index 000000000..efab45078 --- /dev/null +++ b/frontend/public/static/flags/er.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/es.svg b/frontend/public/static/flags/es.svg new file mode 100644 index 000000000..23f0d2f46 --- /dev/null +++ b/frontend/public/static/flags/es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/et.svg b/frontend/public/static/flags/et.svg new file mode 100644 index 000000000..a9c9781b3 --- /dev/null +++ b/frontend/public/static/flags/et.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/eu.svg b/frontend/public/static/flags/eu.svg new file mode 100644 index 000000000..7f04d8a07 --- /dev/null +++ b/frontend/public/static/flags/eu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/fi.svg b/frontend/public/static/flags/fi.svg new file mode 100644 index 000000000..89586a029 --- /dev/null +++ b/frontend/public/static/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/public/static/flags/fj.svg b/frontend/public/static/flags/fj.svg new file mode 100644 index 000000000..81b765b7b --- /dev/null +++ b/frontend/public/static/flags/fj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/fk.svg b/frontend/public/static/flags/fk.svg new file mode 100644 index 000000000..876af98a8 --- /dev/null +++ b/frontend/public/static/flags/fk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/fm.svg b/frontend/public/static/flags/fm.svg new file mode 100644 index 000000000..98009e8c0 --- /dev/null +++ b/frontend/public/static/flags/fm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/fo.svg b/frontend/public/static/flags/fo.svg new file mode 100644 index 000000000..57292f390 --- /dev/null +++ b/frontend/public/static/flags/fo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/static/flags/fr.svg b/frontend/public/static/flags/fr.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/fr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ga.svg b/frontend/public/static/flags/ga.svg new file mode 100644 index 000000000..45e3198d2 --- /dev/null +++ b/frontend/public/static/flags/ga.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/gb-eng.svg b/frontend/public/static/flags/gb-eng.svg new file mode 100644 index 000000000..52e270233 --- /dev/null +++ b/frontend/public/static/flags/gb-eng.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/static/flags/gb-nir.svg b/frontend/public/static/flags/gb-nir.svg new file mode 100644 index 000000000..1b8fb4250 --- /dev/null +++ b/frontend/public/static/flags/gb-nir.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gb-sct.svg b/frontend/public/static/flags/gb-sct.svg new file mode 100644 index 000000000..a016a085e --- /dev/null +++ b/frontend/public/static/flags/gb-sct.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gb-wls.svg b/frontend/public/static/flags/gb-wls.svg new file mode 100644 index 000000000..75bade2af --- /dev/null +++ b/frontend/public/static/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/static/flags/gb.svg b/frontend/public/static/flags/gb.svg new file mode 100644 index 000000000..1b8fb4250 --- /dev/null +++ b/frontend/public/static/flags/gb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gd.svg b/frontend/public/static/flags/gd.svg new file mode 100644 index 000000000..a8ad7304f --- /dev/null +++ b/frontend/public/static/flags/gd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ge.svg b/frontend/public/static/flags/ge.svg new file mode 100644 index 000000000..ed3c23ba0 --- /dev/null +++ b/frontend/public/static/flags/ge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gf.svg b/frontend/public/static/flags/gf.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/gf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gg.svg b/frontend/public/static/flags/gg.svg new file mode 100644 index 000000000..b470de5fc --- /dev/null +++ b/frontend/public/static/flags/gg.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/static/flags/gh.svg b/frontend/public/static/flags/gh.svg new file mode 100644 index 000000000..601733712 --- /dev/null +++ b/frontend/public/static/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/gi.svg b/frontend/public/static/flags/gi.svg new file mode 100644 index 000000000..5a45fbd12 --- /dev/null +++ b/frontend/public/static/flags/gi.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/gl.svg b/frontend/public/static/flags/gl.svg new file mode 100644 index 000000000..00ccf9412 --- /dev/null +++ b/frontend/public/static/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/static/flags/gm.svg b/frontend/public/static/flags/gm.svg new file mode 100644 index 000000000..5e1719c4a --- /dev/null +++ b/frontend/public/static/flags/gm.svg @@ -0,0 +1,7 @@ + +Flag of The Gambia + + + + + diff --git a/frontend/public/static/flags/gn.svg b/frontend/public/static/flags/gn.svg new file mode 100644 index 000000000..4c2621ae2 --- /dev/null +++ b/frontend/public/static/flags/gn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/gp.svg b/frontend/public/static/flags/gp.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/gp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gq.svg b/frontend/public/static/flags/gq.svg new file mode 100644 index 000000000..e2bd36fe2 --- /dev/null +++ b/frontend/public/static/flags/gq.svg @@ -0,0 +1,75 @@ + +Flag of Equatorial Guinea + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/gr.svg b/frontend/public/static/flags/gr.svg new file mode 100644 index 000000000..104ef7cec --- /dev/null +++ b/frontend/public/static/flags/gr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gs.svg b/frontend/public/static/flags/gs.svg new file mode 100644 index 000000000..9089b51d0 --- /dev/null +++ b/frontend/public/static/flags/gs.svg @@ -0,0 +1 @@ +LEOTERRRRREOOAAAMPPPITTMG \ No newline at end of file diff --git a/frontend/public/static/flags/gt.svg b/frontend/public/static/flags/gt.svg new file mode 100644 index 000000000..cf475c971 --- /dev/null +++ b/frontend/public/static/flags/gt.svg @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/gu.svg b/frontend/public/static/flags/gu.svg new file mode 100644 index 000000000..31bda5911 --- /dev/null +++ b/frontend/public/static/flags/gu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/gw.svg b/frontend/public/static/flags/gw.svg new file mode 100644 index 000000000..09d35f361 --- /dev/null +++ b/frontend/public/static/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/gy.svg b/frontend/public/static/flags/gy.svg new file mode 100644 index 000000000..16ce1ad05 --- /dev/null +++ b/frontend/public/static/flags/gy.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/static/flags/hk.svg b/frontend/public/static/flags/hk.svg new file mode 100644 index 000000000..c95457ca0 --- /dev/null +++ b/frontend/public/static/flags/hk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/hm.svg b/frontend/public/static/flags/hm.svg new file mode 100644 index 000000000..bab7eb4d0 --- /dev/null +++ b/frontend/public/static/flags/hm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/hn.svg b/frontend/public/static/flags/hn.svg new file mode 100644 index 000000000..015a7442e --- /dev/null +++ b/frontend/public/static/flags/hn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/hr.svg b/frontend/public/static/flags/hr.svg new file mode 100644 index 000000000..dc5642808 --- /dev/null +++ b/frontend/public/static/flags/hr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ht.svg b/frontend/public/static/flags/ht.svg new file mode 100644 index 000000000..8fb20cf5e --- /dev/null +++ b/frontend/public/static/flags/ht.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/hu.svg b/frontend/public/static/flags/hu.svg new file mode 100644 index 000000000..3ff4c8198 --- /dev/null +++ b/frontend/public/static/flags/hu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/id.svg b/frontend/public/static/flags/id.svg new file mode 100644 index 000000000..dadcc9be4 --- /dev/null +++ b/frontend/public/static/flags/id.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ie.svg b/frontend/public/static/flags/ie.svg new file mode 100644 index 000000000..8c90cc94a --- /dev/null +++ b/frontend/public/static/flags/ie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/il.svg b/frontend/public/static/flags/il.svg new file mode 100644 index 000000000..247500266 --- /dev/null +++ b/frontend/public/static/flags/il.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/im.svg b/frontend/public/static/flags/im.svg new file mode 100644 index 000000000..757c40188 --- /dev/null +++ b/frontend/public/static/flags/im.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/in.svg b/frontend/public/static/flags/in.svg new file mode 100644 index 000000000..c223595d5 --- /dev/null +++ b/frontend/public/static/flags/in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/io.svg b/frontend/public/static/flags/io.svg new file mode 100644 index 000000000..8130500e2 --- /dev/null +++ b/frontend/public/static/flags/io.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/static/flags/iq.svg b/frontend/public/static/flags/iq.svg new file mode 100644 index 000000000..f806fcbe1 --- /dev/null +++ b/frontend/public/static/flags/iq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ir.svg b/frontend/public/static/flags/ir.svg new file mode 100644 index 000000000..59b51b5be --- /dev/null +++ b/frontend/public/static/flags/ir.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/is.svg b/frontend/public/static/flags/is.svg new file mode 100644 index 000000000..2830bde5b --- /dev/null +++ b/frontend/public/static/flags/is.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/it.svg b/frontend/public/static/flags/it.svg new file mode 100644 index 000000000..f9d35b8c6 --- /dev/null +++ b/frontend/public/static/flags/it.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/je.svg b/frontend/public/static/flags/je.svg new file mode 100644 index 000000000..d324787c2 --- /dev/null +++ b/frontend/public/static/flags/je.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/jm.svg b/frontend/public/static/flags/jm.svg new file mode 100644 index 000000000..ee72440b7 --- /dev/null +++ b/frontend/public/static/flags/jm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/jo.svg b/frontend/public/static/flags/jo.svg new file mode 100644 index 000000000..fbcf46807 --- /dev/null +++ b/frontend/public/static/flags/jo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/static/flags/jp.svg b/frontend/public/static/flags/jp.svg new file mode 100644 index 000000000..6299e7327 --- /dev/null +++ b/frontend/public/static/flags/jp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/static/flags/ke.svg b/frontend/public/static/flags/ke.svg new file mode 100644 index 000000000..8f2468b4e --- /dev/null +++ b/frontend/public/static/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/kg.svg b/frontend/public/static/flags/kg.svg new file mode 100644 index 000000000..8487dc9e0 --- /dev/null +++ b/frontend/public/static/flags/kg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/kh.svg b/frontend/public/static/flags/kh.svg new file mode 100644 index 000000000..3d46f6223 --- /dev/null +++ b/frontend/public/static/flags/kh.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/ki.svg b/frontend/public/static/flags/ki.svg new file mode 100644 index 000000000..aa8ea6635 --- /dev/null +++ b/frontend/public/static/flags/ki.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/km.svg b/frontend/public/static/flags/km.svg new file mode 100644 index 000000000..cd7a975f8 --- /dev/null +++ b/frontend/public/static/flags/km.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/kn.svg b/frontend/public/static/flags/kn.svg new file mode 100644 index 000000000..6fd78c60d --- /dev/null +++ b/frontend/public/static/flags/kn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/kp.svg b/frontend/public/static/flags/kp.svg new file mode 100644 index 000000000..d7004824e --- /dev/null +++ b/frontend/public/static/flags/kp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/kr.svg b/frontend/public/static/flags/kr.svg new file mode 100644 index 000000000..a2b3074c1 --- /dev/null +++ b/frontend/public/static/flags/kr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/kw.svg b/frontend/public/static/flags/kw.svg new file mode 100644 index 000000000..44065178e --- /dev/null +++ b/frontend/public/static/flags/kw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/ky.svg b/frontend/public/static/flags/ky.svg new file mode 100644 index 000000000..395664d03 --- /dev/null +++ b/frontend/public/static/flags/ky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/kz.svg b/frontend/public/static/flags/kz.svg new file mode 100644 index 000000000..115aba326 --- /dev/null +++ b/frontend/public/static/flags/kz.svg @@ -0,0 +1,37 @@ + +Flag of Kazakhstan + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/la.svg b/frontend/public/static/flags/la.svg new file mode 100644 index 000000000..b8805ee09 --- /dev/null +++ b/frontend/public/static/flags/la.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/lb.svg b/frontend/public/static/flags/lb.svg new file mode 100644 index 000000000..af7e03d9b --- /dev/null +++ b/frontend/public/static/flags/lb.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/lc.svg b/frontend/public/static/flags/lc.svg new file mode 100644 index 000000000..1249b767c --- /dev/null +++ b/frontend/public/static/flags/lc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/li.svg b/frontend/public/static/flags/li.svg new file mode 100644 index 000000000..e7904fead --- /dev/null +++ b/frontend/public/static/flags/li.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/lk.svg b/frontend/public/static/flags/lk.svg new file mode 100644 index 000000000..cb5e63367 --- /dev/null +++ b/frontend/public/static/flags/lk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/lr.svg b/frontend/public/static/flags/lr.svg new file mode 100644 index 000000000..5fbee8d9e --- /dev/null +++ b/frontend/public/static/flags/lr.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/ls.svg b/frontend/public/static/flags/ls.svg new file mode 100644 index 000000000..4cb99e055 --- /dev/null +++ b/frontend/public/static/flags/ls.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/lt.svg b/frontend/public/static/flags/lt.svg new file mode 100644 index 000000000..690ae4688 --- /dev/null +++ b/frontend/public/static/flags/lt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/lu.svg b/frontend/public/static/flags/lu.svg new file mode 100644 index 000000000..712a3aeb8 --- /dev/null +++ b/frontend/public/static/flags/lu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/lv.svg b/frontend/public/static/flags/lv.svg new file mode 100644 index 000000000..655ac4f42 --- /dev/null +++ b/frontend/public/static/flags/lv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ly.svg b/frontend/public/static/flags/ly.svg new file mode 100644 index 000000000..75db37c20 --- /dev/null +++ b/frontend/public/static/flags/ly.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/static/flags/ma.svg b/frontend/public/static/flags/ma.svg new file mode 100644 index 000000000..9faffa109 --- /dev/null +++ b/frontend/public/static/flags/ma.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mc.svg b/frontend/public/static/flags/mc.svg new file mode 100644 index 000000000..231ec9ba3 --- /dev/null +++ b/frontend/public/static/flags/mc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/md.svg b/frontend/public/static/flags/md.svg new file mode 100644 index 000000000..f4d781d26 --- /dev/null +++ b/frontend/public/static/flags/md.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/me.svg b/frontend/public/static/flags/me.svg new file mode 100644 index 000000000..51a7d9146 --- /dev/null +++ b/frontend/public/static/flags/me.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/mf.svg b/frontend/public/static/flags/mf.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/mf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mg.svg b/frontend/public/static/flags/mg.svg new file mode 100644 index 000000000..6d7dcf41c --- /dev/null +++ b/frontend/public/static/flags/mg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mh.svg b/frontend/public/static/flags/mh.svg new file mode 100644 index 000000000..ae94efb68 --- /dev/null +++ b/frontend/public/static/flags/mh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mk.svg b/frontend/public/static/flags/mk.svg new file mode 100644 index 000000000..06b3cceb8 --- /dev/null +++ b/frontend/public/static/flags/mk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ml.svg b/frontend/public/static/flags/ml.svg new file mode 100644 index 000000000..45f3dd25a --- /dev/null +++ b/frontend/public/static/flags/ml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/mm.svg b/frontend/public/static/flags/mm.svg new file mode 100644 index 000000000..abfdec798 --- /dev/null +++ b/frontend/public/static/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/static/flags/mn.svg b/frontend/public/static/flags/mn.svg new file mode 100644 index 000000000..2af7efec8 --- /dev/null +++ b/frontend/public/static/flags/mn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mo.svg b/frontend/public/static/flags/mo.svg new file mode 100644 index 000000000..3d17d054f --- /dev/null +++ b/frontend/public/static/flags/mo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/static/flags/mp.svg b/frontend/public/static/flags/mp.svg new file mode 100644 index 000000000..f5055c834 --- /dev/null +++ b/frontend/public/static/flags/mp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mq.svg b/frontend/public/static/flags/mq.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/mq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mr.svg b/frontend/public/static/flags/mr.svg new file mode 100644 index 000000000..c9ccfe850 --- /dev/null +++ b/frontend/public/static/flags/mr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ms.svg b/frontend/public/static/flags/ms.svg new file mode 100644 index 000000000..71e497f1b --- /dev/null +++ b/frontend/public/static/flags/ms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mt.svg b/frontend/public/static/flags/mt.svg new file mode 100644 index 000000000..b0fcfd86a --- /dev/null +++ b/frontend/public/static/flags/mt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mu.svg b/frontend/public/static/flags/mu.svg new file mode 100644 index 000000000..f92f29412 --- /dev/null +++ b/frontend/public/static/flags/mu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mv.svg b/frontend/public/static/flags/mv.svg new file mode 100644 index 000000000..5e5e5bb8d --- /dev/null +++ b/frontend/public/static/flags/mv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mw.svg b/frontend/public/static/flags/mw.svg new file mode 100644 index 000000000..4a13318eb --- /dev/null +++ b/frontend/public/static/flags/mw.svg @@ -0,0 +1,24 @@ + +Flag of Malawi + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/mx.svg b/frontend/public/static/flags/mx.svg new file mode 100644 index 000000000..018c46216 --- /dev/null +++ b/frontend/public/static/flags/mx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/my.svg b/frontend/public/static/flags/my.svg new file mode 100644 index 000000000..8cc26dbb0 --- /dev/null +++ b/frontend/public/static/flags/my.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/mz.svg b/frontend/public/static/flags/mz.svg new file mode 100644 index 000000000..ee2f077ef --- /dev/null +++ b/frontend/public/static/flags/mz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/na.svg b/frontend/public/static/flags/na.svg new file mode 100644 index 000000000..328e490cb --- /dev/null +++ b/frontend/public/static/flags/na.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/nc.svg b/frontend/public/static/flags/nc.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/nc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ne.svg b/frontend/public/static/flags/ne.svg new file mode 100644 index 000000000..afe9445c9 --- /dev/null +++ b/frontend/public/static/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/nf.svg b/frontend/public/static/flags/nf.svg new file mode 100644 index 000000000..5187f9186 --- /dev/null +++ b/frontend/public/static/flags/nf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/ng.svg b/frontend/public/static/flags/ng.svg new file mode 100644 index 000000000..87b44f380 --- /dev/null +++ b/frontend/public/static/flags/ng.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/static/flags/ni.svg b/frontend/public/static/flags/ni.svg new file mode 100644 index 000000000..5cc4a59bb --- /dev/null +++ b/frontend/public/static/flags/ni.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/nl.svg b/frontend/public/static/flags/nl.svg new file mode 100644 index 000000000..86c0c376a --- /dev/null +++ b/frontend/public/static/flags/nl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/no.svg b/frontend/public/static/flags/no.svg new file mode 100644 index 000000000..69120a68b --- /dev/null +++ b/frontend/public/static/flags/no.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/np.svg b/frontend/public/static/flags/np.svg new file mode 100644 index 000000000..839dd2a52 --- /dev/null +++ b/frontend/public/static/flags/np.svg @@ -0,0 +1,33 @@ + +Flag of Nepal +Coding according to the official construction in "Constitution of the Kingdom of Nepal, Article 5, Shedule 1", adopted in November 1990 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/nr.svg b/frontend/public/static/flags/nr.svg new file mode 100644 index 000000000..481f8b627 --- /dev/null +++ b/frontend/public/static/flags/nr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/nu.svg b/frontend/public/static/flags/nu.svg new file mode 100644 index 000000000..a2e2aeef0 --- /dev/null +++ b/frontend/public/static/flags/nu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/nz.svg b/frontend/public/static/flags/nz.svg new file mode 100644 index 000000000..db87a7f51 --- /dev/null +++ b/frontend/public/static/flags/nz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/om.svg b/frontend/public/static/flags/om.svg new file mode 100644 index 000000000..9fc902162 --- /dev/null +++ b/frontend/public/static/flags/om.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pa.svg b/frontend/public/static/flags/pa.svg new file mode 100644 index 000000000..d6ddb3fe1 --- /dev/null +++ b/frontend/public/static/flags/pa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pe.svg b/frontend/public/static/flags/pe.svg new file mode 100644 index 000000000..53c47439d --- /dev/null +++ b/frontend/public/static/flags/pe.svg @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/pf.svg b/frontend/public/static/flags/pf.svg new file mode 100644 index 000000000..9f87f90f9 --- /dev/null +++ b/frontend/public/static/flags/pf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pg.svg b/frontend/public/static/flags/pg.svg new file mode 100644 index 000000000..01f8a9dce --- /dev/null +++ b/frontend/public/static/flags/pg.svg @@ -0,0 +1,13 @@ + +Flag of Papua New Guinea + + + + + + + + + + + diff --git a/frontend/public/static/flags/ph.svg b/frontend/public/static/flags/ph.svg new file mode 100644 index 000000000..7970fa18b --- /dev/null +++ b/frontend/public/static/flags/ph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pk.svg b/frontend/public/static/flags/pk.svg new file mode 100644 index 000000000..daab1421e --- /dev/null +++ b/frontend/public/static/flags/pk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pl.svg b/frontend/public/static/flags/pl.svg new file mode 100644 index 000000000..dfb989baa --- /dev/null +++ b/frontend/public/static/flags/pl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pm.svg b/frontend/public/static/flags/pm.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/pm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pn.svg b/frontend/public/static/flags/pn.svg new file mode 100644 index 000000000..2d4d7898d --- /dev/null +++ b/frontend/public/static/flags/pn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/png/ad.png b/frontend/public/static/flags/png/ad.png new file mode 100644 index 000000000..fdc41f6a1 Binary files /dev/null and b/frontend/public/static/flags/png/ad.png differ diff --git a/frontend/public/static/flags/png/ae.png b/frontend/public/static/flags/png/ae.png new file mode 100644 index 000000000..95f7c076d Binary files /dev/null and b/frontend/public/static/flags/png/ae.png differ diff --git a/frontend/public/static/flags/png/af.png b/frontend/public/static/flags/png/af.png new file mode 100644 index 000000000..9b068a9fe Binary files /dev/null and b/frontend/public/static/flags/png/af.png differ diff --git a/frontend/public/static/flags/png/ag.png b/frontend/public/static/flags/png/ag.png new file mode 100644 index 000000000..4b4522a11 Binary files /dev/null and b/frontend/public/static/flags/png/ag.png differ diff --git a/frontend/public/static/flags/png/ai.png b/frontend/public/static/flags/png/ai.png new file mode 100644 index 000000000..9bf45e10c Binary files /dev/null and b/frontend/public/static/flags/png/ai.png differ diff --git a/frontend/public/static/flags/png/al.png b/frontend/public/static/flags/png/al.png new file mode 100644 index 000000000..d5e8397b6 Binary files /dev/null and b/frontend/public/static/flags/png/al.png differ diff --git a/frontend/public/static/flags/png/am.png b/frontend/public/static/flags/png/am.png new file mode 100644 index 000000000..9dd35f0d9 Binary files /dev/null and b/frontend/public/static/flags/png/am.png differ diff --git a/frontend/public/static/flags/png/ao.png b/frontend/public/static/flags/png/ao.png new file mode 100644 index 000000000..8d1571517 Binary files /dev/null and b/frontend/public/static/flags/png/ao.png differ diff --git a/frontend/public/static/flags/png/aq.png b/frontend/public/static/flags/png/aq.png new file mode 100644 index 000000000..b1f6ab9d4 Binary files /dev/null and b/frontend/public/static/flags/png/aq.png differ diff --git a/frontend/public/static/flags/png/ar.png b/frontend/public/static/flags/png/ar.png new file mode 100644 index 000000000..846a2bc14 Binary files /dev/null and b/frontend/public/static/flags/png/ar.png differ diff --git a/frontend/public/static/flags/png/as.png b/frontend/public/static/flags/png/as.png new file mode 100644 index 000000000..60730af67 Binary files /dev/null and b/frontend/public/static/flags/png/as.png differ diff --git a/frontend/public/static/flags/png/at.png b/frontend/public/static/flags/png/at.png new file mode 100644 index 000000000..05dbd1d6f Binary files /dev/null and b/frontend/public/static/flags/png/at.png differ diff --git a/frontend/public/static/flags/png/au.png b/frontend/public/static/flags/png/au.png new file mode 100644 index 000000000..d524c94ee Binary files /dev/null and b/frontend/public/static/flags/png/au.png differ diff --git a/frontend/public/static/flags/png/aw.png b/frontend/public/static/flags/png/aw.png new file mode 100644 index 000000000..3f547cd5a Binary files /dev/null and b/frontend/public/static/flags/png/aw.png differ diff --git a/frontend/public/static/flags/png/ax.png b/frontend/public/static/flags/png/ax.png new file mode 100644 index 000000000..47ef641a8 Binary files /dev/null and b/frontend/public/static/flags/png/ax.png differ diff --git a/frontend/public/static/flags/png/az.png b/frontend/public/static/flags/png/az.png new file mode 100644 index 000000000..5bc4a0f61 Binary files /dev/null and b/frontend/public/static/flags/png/az.png differ diff --git a/frontend/public/static/flags/png/ba.png b/frontend/public/static/flags/png/ba.png new file mode 100644 index 000000000..09cec0d51 Binary files /dev/null and b/frontend/public/static/flags/png/ba.png differ diff --git a/frontend/public/static/flags/png/bb.png b/frontend/public/static/flags/png/bb.png new file mode 100644 index 000000000..ed7731e66 Binary files /dev/null and b/frontend/public/static/flags/png/bb.png differ diff --git a/frontend/public/static/flags/png/bd.png b/frontend/public/static/flags/png/bd.png new file mode 100644 index 000000000..c24170cc7 Binary files /dev/null and b/frontend/public/static/flags/png/bd.png differ diff --git a/frontend/public/static/flags/png/be.png b/frontend/public/static/flags/png/be.png new file mode 100644 index 000000000..202c48990 Binary files /dev/null and b/frontend/public/static/flags/png/be.png differ diff --git a/frontend/public/static/flags/png/bf.png b/frontend/public/static/flags/png/bf.png new file mode 100644 index 000000000..9a3d8d1ce Binary files /dev/null and b/frontend/public/static/flags/png/bf.png differ diff --git a/frontend/public/static/flags/png/bg.png b/frontend/public/static/flags/png/bg.png new file mode 100644 index 000000000..ab3eb4f15 Binary files /dev/null and b/frontend/public/static/flags/png/bg.png differ diff --git a/frontend/public/static/flags/png/bh.png b/frontend/public/static/flags/png/bh.png new file mode 100644 index 000000000..aa7b10bad Binary files /dev/null and b/frontend/public/static/flags/png/bh.png differ diff --git a/frontend/public/static/flags/png/bi.png b/frontend/public/static/flags/png/bi.png new file mode 100644 index 000000000..f4de108e7 Binary files /dev/null and b/frontend/public/static/flags/png/bi.png differ diff --git a/frontend/public/static/flags/png/bj.png b/frontend/public/static/flags/png/bj.png new file mode 100644 index 000000000..83c9474f9 Binary files /dev/null and b/frontend/public/static/flags/png/bj.png differ diff --git a/frontend/public/static/flags/png/bl.png b/frontend/public/static/flags/png/bl.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/bl.png differ diff --git a/frontend/public/static/flags/png/bm.png b/frontend/public/static/flags/png/bm.png new file mode 100644 index 000000000..6ab24793d Binary files /dev/null and b/frontend/public/static/flags/png/bm.png differ diff --git a/frontend/public/static/flags/png/bn.png b/frontend/public/static/flags/png/bn.png new file mode 100644 index 000000000..1006d6882 Binary files /dev/null and b/frontend/public/static/flags/png/bn.png differ diff --git a/frontend/public/static/flags/png/bo.png b/frontend/public/static/flags/png/bo.png new file mode 100644 index 000000000..83db6b81d Binary files /dev/null and b/frontend/public/static/flags/png/bo.png differ diff --git a/frontend/public/static/flags/png/bq.png b/frontend/public/static/flags/png/bq.png new file mode 100644 index 000000000..f545dc87c Binary files /dev/null and b/frontend/public/static/flags/png/bq.png differ diff --git a/frontend/public/static/flags/png/br.png b/frontend/public/static/flags/png/br.png new file mode 100644 index 000000000..8b18eb2c5 Binary files /dev/null and b/frontend/public/static/flags/png/br.png differ diff --git a/frontend/public/static/flags/png/bs.png b/frontend/public/static/flags/png/bs.png new file mode 100644 index 000000000..063b3a6bd Binary files /dev/null and b/frontend/public/static/flags/png/bs.png differ diff --git a/frontend/public/static/flags/png/bt.png b/frontend/public/static/flags/png/bt.png new file mode 100644 index 000000000..013a6a358 Binary files /dev/null and b/frontend/public/static/flags/png/bt.png differ diff --git a/frontend/public/static/flags/png/bv.png b/frontend/public/static/flags/png/bv.png new file mode 100644 index 000000000..b7a42f40c Binary files /dev/null and b/frontend/public/static/flags/png/bv.png differ diff --git a/frontend/public/static/flags/png/bw.png b/frontend/public/static/flags/png/bw.png new file mode 100644 index 000000000..389f367ea Binary files /dev/null and b/frontend/public/static/flags/png/bw.png differ diff --git a/frontend/public/static/flags/png/by.png b/frontend/public/static/flags/png/by.png new file mode 100644 index 000000000..7ad739272 Binary files /dev/null and b/frontend/public/static/flags/png/by.png differ diff --git a/frontend/public/static/flags/png/bz.png b/frontend/public/static/flags/png/bz.png new file mode 100644 index 000000000..1f1b57b25 Binary files /dev/null and b/frontend/public/static/flags/png/bz.png differ diff --git a/frontend/public/static/flags/png/ca.png b/frontend/public/static/flags/png/ca.png new file mode 100644 index 000000000..375740224 Binary files /dev/null and b/frontend/public/static/flags/png/ca.png differ diff --git a/frontend/public/static/flags/png/cc.png b/frontend/public/static/flags/png/cc.png new file mode 100644 index 000000000..919143054 Binary files /dev/null and b/frontend/public/static/flags/png/cc.png differ diff --git a/frontend/public/static/flags/png/cd.png b/frontend/public/static/flags/png/cd.png new file mode 100644 index 000000000..7e85a3dd8 Binary files /dev/null and b/frontend/public/static/flags/png/cd.png differ diff --git a/frontend/public/static/flags/png/cf.png b/frontend/public/static/flags/png/cf.png new file mode 100644 index 000000000..6be2d371f Binary files /dev/null and b/frontend/public/static/flags/png/cf.png differ diff --git a/frontend/public/static/flags/png/cg.png b/frontend/public/static/flags/png/cg.png new file mode 100644 index 000000000..79d62cf30 Binary files /dev/null and b/frontend/public/static/flags/png/cg.png differ diff --git a/frontend/public/static/flags/png/ch.png b/frontend/public/static/flags/png/ch.png new file mode 100644 index 000000000..df9a5ccaa Binary files /dev/null and b/frontend/public/static/flags/png/ch.png differ diff --git a/frontend/public/static/flags/png/ci.png b/frontend/public/static/flags/png/ci.png new file mode 100644 index 000000000..97a79e2b6 Binary files /dev/null and b/frontend/public/static/flags/png/ci.png differ diff --git a/frontend/public/static/flags/png/ck.png b/frontend/public/static/flags/png/ck.png new file mode 100644 index 000000000..3c42e56ea Binary files /dev/null and b/frontend/public/static/flags/png/ck.png differ diff --git a/frontend/public/static/flags/png/cl.png b/frontend/public/static/flags/png/cl.png new file mode 100644 index 000000000..2bd971db1 Binary files /dev/null and b/frontend/public/static/flags/png/cl.png differ diff --git a/frontend/public/static/flags/png/cm.png b/frontend/public/static/flags/png/cm.png new file mode 100644 index 000000000..fa35a3261 Binary files /dev/null and b/frontend/public/static/flags/png/cm.png differ diff --git a/frontend/public/static/flags/png/cn.png b/frontend/public/static/flags/png/cn.png new file mode 100644 index 000000000..5e09030df Binary files /dev/null and b/frontend/public/static/flags/png/cn.png differ diff --git a/frontend/public/static/flags/png/co.png b/frontend/public/static/flags/png/co.png new file mode 100644 index 000000000..6fcfd962a Binary files /dev/null and b/frontend/public/static/flags/png/co.png differ diff --git a/frontend/public/static/flags/png/cr.png b/frontend/public/static/flags/png/cr.png new file mode 100644 index 000000000..c49ff7b09 Binary files /dev/null and b/frontend/public/static/flags/png/cr.png differ diff --git a/frontend/public/static/flags/png/cu.png b/frontend/public/static/flags/png/cu.png new file mode 100644 index 000000000..000b77367 Binary files /dev/null and b/frontend/public/static/flags/png/cu.png differ diff --git a/frontend/public/static/flags/png/cv.png b/frontend/public/static/flags/png/cv.png new file mode 100644 index 000000000..1684576e5 Binary files /dev/null and b/frontend/public/static/flags/png/cv.png differ diff --git a/frontend/public/static/flags/png/cw.png b/frontend/public/static/flags/png/cw.png new file mode 100644 index 000000000..aa7b8c857 Binary files /dev/null and b/frontend/public/static/flags/png/cw.png differ diff --git a/frontend/public/static/flags/png/cx.png b/frontend/public/static/flags/png/cx.png new file mode 100644 index 000000000..826b2309b Binary files /dev/null and b/frontend/public/static/flags/png/cx.png differ diff --git a/frontend/public/static/flags/png/cy.png b/frontend/public/static/flags/png/cy.png new file mode 100644 index 000000000..0d77400c6 Binary files /dev/null and b/frontend/public/static/flags/png/cy.png differ diff --git a/frontend/public/static/flags/png/cz.png b/frontend/public/static/flags/png/cz.png new file mode 100644 index 000000000..8c679f21d Binary files /dev/null and b/frontend/public/static/flags/png/cz.png differ diff --git a/frontend/public/static/flags/png/de.png b/frontend/public/static/flags/png/de.png new file mode 100644 index 000000000..f078fdfe2 Binary files /dev/null and b/frontend/public/static/flags/png/de.png differ diff --git a/frontend/public/static/flags/png/dj.png b/frontend/public/static/flags/png/dj.png new file mode 100644 index 000000000..64ab04e2c Binary files /dev/null and b/frontend/public/static/flags/png/dj.png differ diff --git a/frontend/public/static/flags/png/dk.png b/frontend/public/static/flags/png/dk.png new file mode 100644 index 000000000..5f907cee0 Binary files /dev/null and b/frontend/public/static/flags/png/dk.png differ diff --git a/frontend/public/static/flags/png/dm.png b/frontend/public/static/flags/png/dm.png new file mode 100644 index 000000000..c41bcb790 Binary files /dev/null and b/frontend/public/static/flags/png/dm.png differ diff --git a/frontend/public/static/flags/png/do.png b/frontend/public/static/flags/png/do.png new file mode 100644 index 000000000..bf840bdb9 Binary files /dev/null and b/frontend/public/static/flags/png/do.png differ diff --git a/frontend/public/static/flags/png/dz.png b/frontend/public/static/flags/png/dz.png new file mode 100644 index 000000000..0de125651 Binary files /dev/null and b/frontend/public/static/flags/png/dz.png differ diff --git a/frontend/public/static/flags/png/ec.png b/frontend/public/static/flags/png/ec.png new file mode 100644 index 000000000..96d84f792 Binary files /dev/null and b/frontend/public/static/flags/png/ec.png differ diff --git a/frontend/public/static/flags/png/ee.png b/frontend/public/static/flags/png/ee.png new file mode 100644 index 000000000..73933e3c0 Binary files /dev/null and b/frontend/public/static/flags/png/ee.png differ diff --git a/frontend/public/static/flags/png/eg.png b/frontend/public/static/flags/png/eg.png new file mode 100644 index 000000000..a5320a6b5 Binary files /dev/null and b/frontend/public/static/flags/png/eg.png differ diff --git a/frontend/public/static/flags/png/eh.png b/frontend/public/static/flags/png/eh.png new file mode 100644 index 000000000..24bdb355d Binary files /dev/null and b/frontend/public/static/flags/png/eh.png differ diff --git a/frontend/public/static/flags/png/er.png b/frontend/public/static/flags/png/er.png new file mode 100644 index 000000000..bd47590b9 Binary files /dev/null and b/frontend/public/static/flags/png/er.png differ diff --git a/frontend/public/static/flags/png/es.png b/frontend/public/static/flags/png/es.png new file mode 100644 index 000000000..23e714120 Binary files /dev/null and b/frontend/public/static/flags/png/es.png differ diff --git a/frontend/public/static/flags/png/et.png b/frontend/public/static/flags/png/et.png new file mode 100644 index 000000000..258e32d22 Binary files /dev/null and b/frontend/public/static/flags/png/et.png differ diff --git a/frontend/public/static/flags/png/eu.png b/frontend/public/static/flags/png/eu.png new file mode 100644 index 000000000..0018b6cfd Binary files /dev/null and b/frontend/public/static/flags/png/eu.png differ diff --git a/frontend/public/static/flags/png/fi.png b/frontend/public/static/flags/png/fi.png new file mode 100644 index 000000000..7c1f9087f Binary files /dev/null and b/frontend/public/static/flags/png/fi.png differ diff --git a/frontend/public/static/flags/png/fj.png b/frontend/public/static/flags/png/fj.png new file mode 100644 index 000000000..5ef73c66c Binary files /dev/null and b/frontend/public/static/flags/png/fj.png differ diff --git a/frontend/public/static/flags/png/fk.png b/frontend/public/static/flags/png/fk.png new file mode 100644 index 000000000..6011eb83d Binary files /dev/null and b/frontend/public/static/flags/png/fk.png differ diff --git a/frontend/public/static/flags/png/fm.png b/frontend/public/static/flags/png/fm.png new file mode 100644 index 000000000..8f5b1503b Binary files /dev/null and b/frontend/public/static/flags/png/fm.png differ diff --git a/frontend/public/static/flags/png/fo.png b/frontend/public/static/flags/png/fo.png new file mode 100644 index 000000000..6a1e04805 Binary files /dev/null and b/frontend/public/static/flags/png/fo.png differ diff --git a/frontend/public/static/flags/png/fr.png b/frontend/public/static/flags/png/fr.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/fr.png differ diff --git a/frontend/public/static/flags/png/ga.png b/frontend/public/static/flags/png/ga.png new file mode 100644 index 000000000..dcade0052 Binary files /dev/null and b/frontend/public/static/flags/png/ga.png differ diff --git a/frontend/public/static/flags/png/gb-eng.png b/frontend/public/static/flags/png/gb-eng.png new file mode 100644 index 000000000..b534e6289 Binary files /dev/null and b/frontend/public/static/flags/png/gb-eng.png differ diff --git a/frontend/public/static/flags/png/gb-nir.png b/frontend/public/static/flags/png/gb-nir.png new file mode 100644 index 000000000..cc7cb3d54 Binary files /dev/null and b/frontend/public/static/flags/png/gb-nir.png differ diff --git a/frontend/public/static/flags/png/gb-sct.png b/frontend/public/static/flags/png/gb-sct.png new file mode 100644 index 000000000..746b3c9e2 Binary files /dev/null and b/frontend/public/static/flags/png/gb-sct.png differ diff --git a/frontend/public/static/flags/png/gb-wls.png b/frontend/public/static/flags/png/gb-wls.png new file mode 100644 index 000000000..9571ac5e9 Binary files /dev/null and b/frontend/public/static/flags/png/gb-wls.png differ diff --git a/frontend/public/static/flags/png/gb.png b/frontend/public/static/flags/png/gb.png new file mode 100644 index 000000000..cc7cb3d54 Binary files /dev/null and b/frontend/public/static/flags/png/gb.png differ diff --git a/frontend/public/static/flags/png/gd.png b/frontend/public/static/flags/png/gd.png new file mode 100644 index 000000000..2c070dd78 Binary files /dev/null and b/frontend/public/static/flags/png/gd.png differ diff --git a/frontend/public/static/flags/png/ge.png b/frontend/public/static/flags/png/ge.png new file mode 100644 index 000000000..1cc52042e Binary files /dev/null and b/frontend/public/static/flags/png/ge.png differ diff --git a/frontend/public/static/flags/png/gf.png b/frontend/public/static/flags/png/gf.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/gf.png differ diff --git a/frontend/public/static/flags/png/gg.png b/frontend/public/static/flags/png/gg.png new file mode 100644 index 000000000..e7343041f Binary files /dev/null and b/frontend/public/static/flags/png/gg.png differ diff --git a/frontend/public/static/flags/png/gh.png b/frontend/public/static/flags/png/gh.png new file mode 100644 index 000000000..a8ef8c458 Binary files /dev/null and b/frontend/public/static/flags/png/gh.png differ diff --git a/frontend/public/static/flags/png/gi.png b/frontend/public/static/flags/png/gi.png new file mode 100644 index 000000000..ca2b8dafa Binary files /dev/null and b/frontend/public/static/flags/png/gi.png differ diff --git a/frontend/public/static/flags/png/gl.png b/frontend/public/static/flags/png/gl.png new file mode 100644 index 000000000..3a8ce02a9 Binary files /dev/null and b/frontend/public/static/flags/png/gl.png differ diff --git a/frontend/public/static/flags/png/gm.png b/frontend/public/static/flags/png/gm.png new file mode 100644 index 000000000..2a70b15d5 Binary files /dev/null and b/frontend/public/static/flags/png/gm.png differ diff --git a/frontend/public/static/flags/png/gn.png b/frontend/public/static/flags/png/gn.png new file mode 100644 index 000000000..609fd6645 Binary files /dev/null and b/frontend/public/static/flags/png/gn.png differ diff --git a/frontend/public/static/flags/png/gp.png b/frontend/public/static/flags/png/gp.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/gp.png differ diff --git a/frontend/public/static/flags/png/gq.png b/frontend/public/static/flags/png/gq.png new file mode 100644 index 000000000..60a82d9c5 Binary files /dev/null and b/frontend/public/static/flags/png/gq.png differ diff --git a/frontend/public/static/flags/png/gr.png b/frontend/public/static/flags/png/gr.png new file mode 100644 index 000000000..ac2f75c5c Binary files /dev/null and b/frontend/public/static/flags/png/gr.png differ diff --git a/frontend/public/static/flags/png/gs.png b/frontend/public/static/flags/png/gs.png new file mode 100644 index 000000000..eb65121ca Binary files /dev/null and b/frontend/public/static/flags/png/gs.png differ diff --git a/frontend/public/static/flags/png/gt.png b/frontend/public/static/flags/png/gt.png new file mode 100644 index 000000000..4971a581f Binary files /dev/null and b/frontend/public/static/flags/png/gt.png differ diff --git a/frontend/public/static/flags/png/gu.png b/frontend/public/static/flags/png/gu.png new file mode 100644 index 000000000..8c00cc35d Binary files /dev/null and b/frontend/public/static/flags/png/gu.png differ diff --git a/frontend/public/static/flags/png/gw.png b/frontend/public/static/flags/png/gw.png new file mode 100644 index 000000000..1472f2370 Binary files /dev/null and b/frontend/public/static/flags/png/gw.png differ diff --git a/frontend/public/static/flags/png/gy.png b/frontend/public/static/flags/png/gy.png new file mode 100644 index 000000000..36279ec5c Binary files /dev/null and b/frontend/public/static/flags/png/gy.png differ diff --git a/frontend/public/static/flags/png/hk.png b/frontend/public/static/flags/png/hk.png new file mode 100644 index 000000000..36f6043ab Binary files /dev/null and b/frontend/public/static/flags/png/hk.png differ diff --git a/frontend/public/static/flags/png/hm.png b/frontend/public/static/flags/png/hm.png new file mode 100644 index 000000000..d524c94ee Binary files /dev/null and b/frontend/public/static/flags/png/hm.png differ diff --git a/frontend/public/static/flags/png/hn.png b/frontend/public/static/flags/png/hn.png new file mode 100644 index 000000000..887394fe7 Binary files /dev/null and b/frontend/public/static/flags/png/hn.png differ diff --git a/frontend/public/static/flags/png/hr.png b/frontend/public/static/flags/png/hr.png new file mode 100644 index 000000000..66647d5d2 Binary files /dev/null and b/frontend/public/static/flags/png/hr.png differ diff --git a/frontend/public/static/flags/png/ht.png b/frontend/public/static/flags/png/ht.png new file mode 100644 index 000000000..d4c8a693e Binary files /dev/null and b/frontend/public/static/flags/png/ht.png differ diff --git a/frontend/public/static/flags/png/hu.png b/frontend/public/static/flags/png/hu.png new file mode 100644 index 000000000..315eede87 Binary files /dev/null and b/frontend/public/static/flags/png/hu.png differ diff --git a/frontend/public/static/flags/png/id.png b/frontend/public/static/flags/png/id.png new file mode 100644 index 000000000..3e4d02c5a Binary files /dev/null and b/frontend/public/static/flags/png/id.png differ diff --git a/frontend/public/static/flags/png/ie.png b/frontend/public/static/flags/png/ie.png new file mode 100644 index 000000000..67f2822db Binary files /dev/null and b/frontend/public/static/flags/png/ie.png differ diff --git a/frontend/public/static/flags/png/il.png b/frontend/public/static/flags/png/il.png new file mode 100644 index 000000000..32aba13b1 Binary files /dev/null and b/frontend/public/static/flags/png/il.png differ diff --git a/frontend/public/static/flags/png/im.png b/frontend/public/static/flags/png/im.png new file mode 100644 index 000000000..f02398eb7 Binary files /dev/null and b/frontend/public/static/flags/png/im.png differ diff --git a/frontend/public/static/flags/png/in.png b/frontend/public/static/flags/png/in.png new file mode 100644 index 000000000..f30d401f6 Binary files /dev/null and b/frontend/public/static/flags/png/in.png differ diff --git a/frontend/public/static/flags/png/io.png b/frontend/public/static/flags/png/io.png new file mode 100644 index 000000000..469a8f30d Binary files /dev/null and b/frontend/public/static/flags/png/io.png differ diff --git a/frontend/public/static/flags/png/iq.png b/frontend/public/static/flags/png/iq.png new file mode 100644 index 000000000..ba6ac0ec0 Binary files /dev/null and b/frontend/public/static/flags/png/iq.png differ diff --git a/frontend/public/static/flags/png/ir.png b/frontend/public/static/flags/png/ir.png new file mode 100644 index 000000000..e218c8389 Binary files /dev/null and b/frontend/public/static/flags/png/ir.png differ diff --git a/frontend/public/static/flags/png/is.png b/frontend/public/static/flags/png/is.png new file mode 100644 index 000000000..5cbb8b8d1 Binary files /dev/null and b/frontend/public/static/flags/png/is.png differ diff --git a/frontend/public/static/flags/png/it.png b/frontend/public/static/flags/png/it.png new file mode 100644 index 000000000..e9c1a9a8e Binary files /dev/null and b/frontend/public/static/flags/png/it.png differ diff --git a/frontend/public/static/flags/png/je.png b/frontend/public/static/flags/png/je.png new file mode 100644 index 000000000..0e26cb981 Binary files /dev/null and b/frontend/public/static/flags/png/je.png differ diff --git a/frontend/public/static/flags/png/jm.png b/frontend/public/static/flags/png/jm.png new file mode 100644 index 000000000..105c53965 Binary files /dev/null and b/frontend/public/static/flags/png/jm.png differ diff --git a/frontend/public/static/flags/png/jo.png b/frontend/public/static/flags/png/jo.png new file mode 100644 index 000000000..d5b39dafe Binary files /dev/null and b/frontend/public/static/flags/png/jo.png differ diff --git a/frontend/public/static/flags/png/jp.png b/frontend/public/static/flags/png/jp.png new file mode 100644 index 000000000..253e485ea Binary files /dev/null and b/frontend/public/static/flags/png/jp.png differ diff --git a/frontend/public/static/flags/png/ke.png b/frontend/public/static/flags/png/ke.png new file mode 100644 index 000000000..3017b3b77 Binary files /dev/null and b/frontend/public/static/flags/png/ke.png differ diff --git a/frontend/public/static/flags/png/kg.png b/frontend/public/static/flags/png/kg.png new file mode 100644 index 000000000..756a8c710 Binary files /dev/null and b/frontend/public/static/flags/png/kg.png differ diff --git a/frontend/public/static/flags/png/kh.png b/frontend/public/static/flags/png/kh.png new file mode 100644 index 000000000..74106c59a Binary files /dev/null and b/frontend/public/static/flags/png/kh.png differ diff --git a/frontend/public/static/flags/png/ki.png b/frontend/public/static/flags/png/ki.png new file mode 100644 index 000000000..571ea1a98 Binary files /dev/null and b/frontend/public/static/flags/png/ki.png differ diff --git a/frontend/public/static/flags/png/km.png b/frontend/public/static/flags/png/km.png new file mode 100644 index 000000000..22d976095 Binary files /dev/null and b/frontend/public/static/flags/png/km.png differ diff --git a/frontend/public/static/flags/png/kn.png b/frontend/public/static/flags/png/kn.png new file mode 100644 index 000000000..be0a58650 Binary files /dev/null and b/frontend/public/static/flags/png/kn.png differ diff --git a/frontend/public/static/flags/png/kp.png b/frontend/public/static/flags/png/kp.png new file mode 100644 index 000000000..1d9ca5e83 Binary files /dev/null and b/frontend/public/static/flags/png/kp.png differ diff --git a/frontend/public/static/flags/png/kr.png b/frontend/public/static/flags/png/kr.png new file mode 100644 index 000000000..a2a27dcb9 Binary files /dev/null and b/frontend/public/static/flags/png/kr.png differ diff --git a/frontend/public/static/flags/png/kw.png b/frontend/public/static/flags/png/kw.png new file mode 100644 index 000000000..a93906193 Binary files /dev/null and b/frontend/public/static/flags/png/kw.png differ diff --git a/frontend/public/static/flags/png/ky.png b/frontend/public/static/flags/png/ky.png new file mode 100644 index 000000000..934224063 Binary files /dev/null and b/frontend/public/static/flags/png/ky.png differ diff --git a/frontend/public/static/flags/png/kz.png b/frontend/public/static/flags/png/kz.png new file mode 100644 index 000000000..b3bb7f0c6 Binary files /dev/null and b/frontend/public/static/flags/png/kz.png differ diff --git a/frontend/public/static/flags/png/la.png b/frontend/public/static/flags/png/la.png new file mode 100644 index 000000000..006e38ffb Binary files /dev/null and b/frontend/public/static/flags/png/la.png differ diff --git a/frontend/public/static/flags/png/lb.png b/frontend/public/static/flags/png/lb.png new file mode 100644 index 000000000..a2d62a8ee Binary files /dev/null and b/frontend/public/static/flags/png/lb.png differ diff --git a/frontend/public/static/flags/png/lc.png b/frontend/public/static/flags/png/lc.png new file mode 100644 index 000000000..40948b60f Binary files /dev/null and b/frontend/public/static/flags/png/lc.png differ diff --git a/frontend/public/static/flags/png/li.png b/frontend/public/static/flags/png/li.png new file mode 100644 index 000000000..8d73ed84b Binary files /dev/null and b/frontend/public/static/flags/png/li.png differ diff --git a/frontend/public/static/flags/png/lk.png b/frontend/public/static/flags/png/lk.png new file mode 100644 index 000000000..d7ccd473c Binary files /dev/null and b/frontend/public/static/flags/png/lk.png differ diff --git a/frontend/public/static/flags/png/lr.png b/frontend/public/static/flags/png/lr.png new file mode 100644 index 000000000..b20d7f0e1 Binary files /dev/null and b/frontend/public/static/flags/png/lr.png differ diff --git a/frontend/public/static/flags/png/ls.png b/frontend/public/static/flags/png/ls.png new file mode 100644 index 000000000..75d9ce353 Binary files /dev/null and b/frontend/public/static/flags/png/ls.png differ diff --git a/frontend/public/static/flags/png/lt.png b/frontend/public/static/flags/png/lt.png new file mode 100644 index 000000000..ccedae785 Binary files /dev/null and b/frontend/public/static/flags/png/lt.png differ diff --git a/frontend/public/static/flags/png/lu.png b/frontend/public/static/flags/png/lu.png new file mode 100644 index 000000000..07c0f75d3 Binary files /dev/null and b/frontend/public/static/flags/png/lu.png differ diff --git a/frontend/public/static/flags/png/lv.png b/frontend/public/static/flags/png/lv.png new file mode 100644 index 000000000..2e3cf81ac Binary files /dev/null and b/frontend/public/static/flags/png/lv.png differ diff --git a/frontend/public/static/flags/png/ly.png b/frontend/public/static/flags/png/ly.png new file mode 100644 index 000000000..d898e4967 Binary files /dev/null and b/frontend/public/static/flags/png/ly.png differ diff --git a/frontend/public/static/flags/png/ma.png b/frontend/public/static/flags/png/ma.png new file mode 100644 index 000000000..6d44983c0 Binary files /dev/null and b/frontend/public/static/flags/png/ma.png differ diff --git a/frontend/public/static/flags/png/mc.png b/frontend/public/static/flags/png/mc.png new file mode 100644 index 000000000..f40ca3ef9 Binary files /dev/null and b/frontend/public/static/flags/png/mc.png differ diff --git a/frontend/public/static/flags/png/md.png b/frontend/public/static/flags/png/md.png new file mode 100644 index 000000000..b57117873 Binary files /dev/null and b/frontend/public/static/flags/png/md.png differ diff --git a/frontend/public/static/flags/png/me.png b/frontend/public/static/flags/png/me.png new file mode 100644 index 000000000..1d568555f Binary files /dev/null and b/frontend/public/static/flags/png/me.png differ diff --git a/frontend/public/static/flags/png/mf.png b/frontend/public/static/flags/png/mf.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/mf.png differ diff --git a/frontend/public/static/flags/png/mg.png b/frontend/public/static/flags/png/mg.png new file mode 100644 index 000000000..21ccba366 Binary files /dev/null and b/frontend/public/static/flags/png/mg.png differ diff --git a/frontend/public/static/flags/png/mh.png b/frontend/public/static/flags/png/mh.png new file mode 100644 index 000000000..bb78d47cc Binary files /dev/null and b/frontend/public/static/flags/png/mh.png differ diff --git a/frontend/public/static/flags/png/mk.png b/frontend/public/static/flags/png/mk.png new file mode 100644 index 000000000..d8c3b4ca2 Binary files /dev/null and b/frontend/public/static/flags/png/mk.png differ diff --git a/frontend/public/static/flags/png/ml.png b/frontend/public/static/flags/png/ml.png new file mode 100644 index 000000000..f0e9990f6 Binary files /dev/null and b/frontend/public/static/flags/png/ml.png differ diff --git a/frontend/public/static/flags/png/mm.png b/frontend/public/static/flags/png/mm.png new file mode 100644 index 000000000..09363b3f2 Binary files /dev/null and b/frontend/public/static/flags/png/mm.png differ diff --git a/frontend/public/static/flags/png/mn.png b/frontend/public/static/flags/png/mn.png new file mode 100644 index 000000000..7f8ab853a Binary files /dev/null and b/frontend/public/static/flags/png/mn.png differ diff --git a/frontend/public/static/flags/png/mo.png b/frontend/public/static/flags/png/mo.png new file mode 100644 index 000000000..4a731ce45 Binary files /dev/null and b/frontend/public/static/flags/png/mo.png differ diff --git a/frontend/public/static/flags/png/mp.png b/frontend/public/static/flags/png/mp.png new file mode 100644 index 000000000..85424687a Binary files /dev/null and b/frontend/public/static/flags/png/mp.png differ diff --git a/frontend/public/static/flags/png/mq.png b/frontend/public/static/flags/png/mq.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/mq.png differ diff --git a/frontend/public/static/flags/png/mr.png b/frontend/public/static/flags/png/mr.png new file mode 100644 index 000000000..66a6a14f1 Binary files /dev/null and b/frontend/public/static/flags/png/mr.png differ diff --git a/frontend/public/static/flags/png/ms.png b/frontend/public/static/flags/png/ms.png new file mode 100644 index 000000000..3cd7c7ec9 Binary files /dev/null and b/frontend/public/static/flags/png/ms.png differ diff --git a/frontend/public/static/flags/png/mt.png b/frontend/public/static/flags/png/mt.png new file mode 100644 index 000000000..3c29e3a22 Binary files /dev/null and b/frontend/public/static/flags/png/mt.png differ diff --git a/frontend/public/static/flags/png/mu.png b/frontend/public/static/flags/png/mu.png new file mode 100644 index 000000000..b41654629 Binary files /dev/null and b/frontend/public/static/flags/png/mu.png differ diff --git a/frontend/public/static/flags/png/mv.png b/frontend/public/static/flags/png/mv.png new file mode 100644 index 000000000..a011b7820 Binary files /dev/null and b/frontend/public/static/flags/png/mv.png differ diff --git a/frontend/public/static/flags/png/mw.png b/frontend/public/static/flags/png/mw.png new file mode 100644 index 000000000..582197714 Binary files /dev/null and b/frontend/public/static/flags/png/mw.png differ diff --git a/frontend/public/static/flags/png/mx.png b/frontend/public/static/flags/png/mx.png new file mode 100644 index 000000000..94f48139a Binary files /dev/null and b/frontend/public/static/flags/png/mx.png differ diff --git a/frontend/public/static/flags/png/my.png b/frontend/public/static/flags/png/my.png new file mode 100644 index 000000000..fca599413 Binary files /dev/null and b/frontend/public/static/flags/png/my.png differ diff --git a/frontend/public/static/flags/png/mz.png b/frontend/public/static/flags/png/mz.png new file mode 100644 index 000000000..81c587d06 Binary files /dev/null and b/frontend/public/static/flags/png/mz.png differ diff --git a/frontend/public/static/flags/png/na.png b/frontend/public/static/flags/png/na.png new file mode 100644 index 000000000..1a4f70960 Binary files /dev/null and b/frontend/public/static/flags/png/na.png differ diff --git a/frontend/public/static/flags/png/nc.png b/frontend/public/static/flags/png/nc.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/nc.png differ diff --git a/frontend/public/static/flags/png/ne.png b/frontend/public/static/flags/png/ne.png new file mode 100644 index 000000000..fa8859a59 Binary files /dev/null and b/frontend/public/static/flags/png/ne.png differ diff --git a/frontend/public/static/flags/png/nf.png b/frontend/public/static/flags/png/nf.png new file mode 100644 index 000000000..3c94505b3 Binary files /dev/null and b/frontend/public/static/flags/png/nf.png differ diff --git a/frontend/public/static/flags/png/ng.png b/frontend/public/static/flags/png/ng.png new file mode 100644 index 000000000..fd111eb07 Binary files /dev/null and b/frontend/public/static/flags/png/ng.png differ diff --git a/frontend/public/static/flags/png/ni.png b/frontend/public/static/flags/png/ni.png new file mode 100644 index 000000000..fa964780a Binary files /dev/null and b/frontend/public/static/flags/png/ni.png differ diff --git a/frontend/public/static/flags/png/nl.png b/frontend/public/static/flags/png/nl.png new file mode 100644 index 000000000..f545dc87c Binary files /dev/null and b/frontend/public/static/flags/png/nl.png differ diff --git a/frontend/public/static/flags/png/no.png b/frontend/public/static/flags/png/no.png new file mode 100644 index 000000000..b7a42f40c Binary files /dev/null and b/frontend/public/static/flags/png/no.png differ diff --git a/frontend/public/static/flags/png/np.png b/frontend/public/static/flags/png/np.png new file mode 100644 index 000000000..7f2ffbedf Binary files /dev/null and b/frontend/public/static/flags/png/np.png differ diff --git a/frontend/public/static/flags/png/nr.png b/frontend/public/static/flags/png/nr.png new file mode 100644 index 000000000..abaae2328 Binary files /dev/null and b/frontend/public/static/flags/png/nr.png differ diff --git a/frontend/public/static/flags/png/nu.png b/frontend/public/static/flags/png/nu.png new file mode 100644 index 000000000..ee6da25d6 Binary files /dev/null and b/frontend/public/static/flags/png/nu.png differ diff --git a/frontend/public/static/flags/png/nz.png b/frontend/public/static/flags/png/nz.png new file mode 100644 index 000000000..93501eb47 Binary files /dev/null and b/frontend/public/static/flags/png/nz.png differ diff --git a/frontend/public/static/flags/png/om.png b/frontend/public/static/flags/png/om.png new file mode 100644 index 000000000..fcfd30205 Binary files /dev/null and b/frontend/public/static/flags/png/om.png differ diff --git a/frontend/public/static/flags/png/pa.png b/frontend/public/static/flags/png/pa.png new file mode 100644 index 000000000..175310c5b Binary files /dev/null and b/frontend/public/static/flags/png/pa.png differ diff --git a/frontend/public/static/flags/png/pe.png b/frontend/public/static/flags/png/pe.png new file mode 100644 index 000000000..2bed96a32 Binary files /dev/null and b/frontend/public/static/flags/png/pe.png differ diff --git a/frontend/public/static/flags/png/pf.png b/frontend/public/static/flags/png/pf.png new file mode 100644 index 000000000..fed09f9d4 Binary files /dev/null and b/frontend/public/static/flags/png/pf.png differ diff --git a/frontend/public/static/flags/png/pg.png b/frontend/public/static/flags/png/pg.png new file mode 100644 index 000000000..c18cc5cbe Binary files /dev/null and b/frontend/public/static/flags/png/pg.png differ diff --git a/frontend/public/static/flags/png/ph.png b/frontend/public/static/flags/png/ph.png new file mode 100644 index 000000000..6f73a4f3a Binary files /dev/null and b/frontend/public/static/flags/png/ph.png differ diff --git a/frontend/public/static/flags/png/pk.png b/frontend/public/static/flags/png/pk.png new file mode 100644 index 000000000..b2355db58 Binary files /dev/null and b/frontend/public/static/flags/png/pk.png differ diff --git a/frontend/public/static/flags/png/pl.png b/frontend/public/static/flags/png/pl.png new file mode 100644 index 000000000..e335edb72 Binary files /dev/null and b/frontend/public/static/flags/png/pl.png differ diff --git a/frontend/public/static/flags/png/pm.png b/frontend/public/static/flags/png/pm.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/pm.png differ diff --git a/frontend/public/static/flags/png/pn.png b/frontend/public/static/flags/png/pn.png new file mode 100644 index 000000000..15e9ce648 Binary files /dev/null and b/frontend/public/static/flags/png/pn.png differ diff --git a/frontend/public/static/flags/png/pr.png b/frontend/public/static/flags/png/pr.png new file mode 100644 index 000000000..4a870a18a Binary files /dev/null and b/frontend/public/static/flags/png/pr.png differ diff --git a/frontend/public/static/flags/png/ps.png b/frontend/public/static/flags/png/ps.png new file mode 100644 index 000000000..cac9ec76e Binary files /dev/null and b/frontend/public/static/flags/png/ps.png differ diff --git a/frontend/public/static/flags/png/pt.png b/frontend/public/static/flags/png/pt.png new file mode 100644 index 000000000..75f877740 Binary files /dev/null and b/frontend/public/static/flags/png/pt.png differ diff --git a/frontend/public/static/flags/png/pw.png b/frontend/public/static/flags/png/pw.png new file mode 100644 index 000000000..2866217e0 Binary files /dev/null and b/frontend/public/static/flags/png/pw.png differ diff --git a/frontend/public/static/flags/png/py.png b/frontend/public/static/flags/png/py.png new file mode 100644 index 000000000..16cc30d19 Binary files /dev/null and b/frontend/public/static/flags/png/py.png differ diff --git a/frontend/public/static/flags/png/qa.png b/frontend/public/static/flags/png/qa.png new file mode 100644 index 000000000..be7986deb Binary files /dev/null and b/frontend/public/static/flags/png/qa.png differ diff --git a/frontend/public/static/flags/png/re.png b/frontend/public/static/flags/png/re.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/re.png differ diff --git a/frontend/public/static/flags/png/ro.png b/frontend/public/static/flags/png/ro.png new file mode 100644 index 000000000..b8c25cec4 Binary files /dev/null and b/frontend/public/static/flags/png/ro.png differ diff --git a/frontend/public/static/flags/png/rs.png b/frontend/public/static/flags/png/rs.png new file mode 100644 index 000000000..dc1e1a3f9 Binary files /dev/null and b/frontend/public/static/flags/png/rs.png differ diff --git a/frontend/public/static/flags/png/ru.png b/frontend/public/static/flags/png/ru.png new file mode 100644 index 000000000..6418bf859 Binary files /dev/null and b/frontend/public/static/flags/png/ru.png differ diff --git a/frontend/public/static/flags/png/rw.png b/frontend/public/static/flags/png/rw.png new file mode 100644 index 000000000..c01b9a659 Binary files /dev/null and b/frontend/public/static/flags/png/rw.png differ diff --git a/frontend/public/static/flags/png/sa.png b/frontend/public/static/flags/png/sa.png new file mode 100644 index 000000000..e5743f5d8 Binary files /dev/null and b/frontend/public/static/flags/png/sa.png differ diff --git a/frontend/public/static/flags/png/sb.png b/frontend/public/static/flags/png/sb.png new file mode 100644 index 000000000..ef529e13a Binary files /dev/null and b/frontend/public/static/flags/png/sb.png differ diff --git a/frontend/public/static/flags/png/sc.png b/frontend/public/static/flags/png/sc.png new file mode 100644 index 000000000..a743a489d Binary files /dev/null and b/frontend/public/static/flags/png/sc.png differ diff --git a/frontend/public/static/flags/png/sd.png b/frontend/public/static/flags/png/sd.png new file mode 100644 index 000000000..117ad21f6 Binary files /dev/null and b/frontend/public/static/flags/png/sd.png differ diff --git a/frontend/public/static/flags/png/se.png b/frontend/public/static/flags/png/se.png new file mode 100644 index 000000000..b6fcc6285 Binary files /dev/null and b/frontend/public/static/flags/png/se.png differ diff --git a/frontend/public/static/flags/png/sg.png b/frontend/public/static/flags/png/sg.png new file mode 100644 index 000000000..65a0422fd Binary files /dev/null and b/frontend/public/static/flags/png/sg.png differ diff --git a/frontend/public/static/flags/png/sh.png b/frontend/public/static/flags/png/sh.png new file mode 100644 index 000000000..cc7cb3d54 Binary files /dev/null and b/frontend/public/static/flags/png/sh.png differ diff --git a/frontend/public/static/flags/png/si.png b/frontend/public/static/flags/png/si.png new file mode 100644 index 000000000..e1a4c0145 Binary files /dev/null and b/frontend/public/static/flags/png/si.png differ diff --git a/frontend/public/static/flags/png/sj.png b/frontend/public/static/flags/png/sj.png new file mode 100644 index 000000000..b7a42f40c Binary files /dev/null and b/frontend/public/static/flags/png/sj.png differ diff --git a/frontend/public/static/flags/png/sk.png b/frontend/public/static/flags/png/sk.png new file mode 100644 index 000000000..d5e8f1ebe Binary files /dev/null and b/frontend/public/static/flags/png/sk.png differ diff --git a/frontend/public/static/flags/png/sl.png b/frontend/public/static/flags/png/sl.png new file mode 100644 index 000000000..3762e24e2 Binary files /dev/null and b/frontend/public/static/flags/png/sl.png differ diff --git a/frontend/public/static/flags/png/sm.png b/frontend/public/static/flags/png/sm.png new file mode 100644 index 000000000..0734639a6 Binary files /dev/null and b/frontend/public/static/flags/png/sm.png differ diff --git a/frontend/public/static/flags/png/sn.png b/frontend/public/static/flags/png/sn.png new file mode 100644 index 000000000..719c1ef37 Binary files /dev/null and b/frontend/public/static/flags/png/sn.png differ diff --git a/frontend/public/static/flags/png/so.png b/frontend/public/static/flags/png/so.png new file mode 100644 index 000000000..6e784b1a3 Binary files /dev/null and b/frontend/public/static/flags/png/so.png differ diff --git a/frontend/public/static/flags/png/sr.png b/frontend/public/static/flags/png/sr.png new file mode 100644 index 000000000..336ab87b3 Binary files /dev/null and b/frontend/public/static/flags/png/sr.png differ diff --git a/frontend/public/static/flags/png/ss.png b/frontend/public/static/flags/png/ss.png new file mode 100644 index 000000000..dde5ad1b2 Binary files /dev/null and b/frontend/public/static/flags/png/ss.png differ diff --git a/frontend/public/static/flags/png/st.png b/frontend/public/static/flags/png/st.png new file mode 100644 index 000000000..133edbc09 Binary files /dev/null and b/frontend/public/static/flags/png/st.png differ diff --git a/frontend/public/static/flags/png/sv.png b/frontend/public/static/flags/png/sv.png new file mode 100644 index 000000000..8b8927a5b Binary files /dev/null and b/frontend/public/static/flags/png/sv.png differ diff --git a/frontend/public/static/flags/png/sx.png b/frontend/public/static/flags/png/sx.png new file mode 100644 index 000000000..3e347b01c Binary files /dev/null and b/frontend/public/static/flags/png/sx.png differ diff --git a/frontend/public/static/flags/png/sy.png b/frontend/public/static/flags/png/sy.png new file mode 100644 index 000000000..367a15adb Binary files /dev/null and b/frontend/public/static/flags/png/sy.png differ diff --git a/frontend/public/static/flags/png/sz.png b/frontend/public/static/flags/png/sz.png new file mode 100644 index 000000000..5b413eb2a Binary files /dev/null and b/frontend/public/static/flags/png/sz.png differ diff --git a/frontend/public/static/flags/png/tc.png b/frontend/public/static/flags/png/tc.png new file mode 100644 index 000000000..f593c2a53 Binary files /dev/null and b/frontend/public/static/flags/png/tc.png differ diff --git a/frontend/public/static/flags/png/td.png b/frontend/public/static/flags/png/td.png new file mode 100644 index 000000000..e94b93999 Binary files /dev/null and b/frontend/public/static/flags/png/td.png differ diff --git a/frontend/public/static/flags/png/tf.png b/frontend/public/static/flags/png/tf.png new file mode 100644 index 000000000..5189a18f4 Binary files /dev/null and b/frontend/public/static/flags/png/tf.png differ diff --git a/frontend/public/static/flags/png/tg.png b/frontend/public/static/flags/png/tg.png new file mode 100644 index 000000000..028167aa0 Binary files /dev/null and b/frontend/public/static/flags/png/tg.png differ diff --git a/frontend/public/static/flags/png/th.png b/frontend/public/static/flags/png/th.png new file mode 100644 index 000000000..cd0af74d8 Binary files /dev/null and b/frontend/public/static/flags/png/th.png differ diff --git a/frontend/public/static/flags/png/tj.png b/frontend/public/static/flags/png/tj.png new file mode 100644 index 000000000..6cb333971 Binary files /dev/null and b/frontend/public/static/flags/png/tj.png differ diff --git a/frontend/public/static/flags/png/tk.png b/frontend/public/static/flags/png/tk.png new file mode 100644 index 000000000..7c71400c4 Binary files /dev/null and b/frontend/public/static/flags/png/tk.png differ diff --git a/frontend/public/static/flags/png/tl.png b/frontend/public/static/flags/png/tl.png new file mode 100644 index 000000000..91d1e1efe Binary files /dev/null and b/frontend/public/static/flags/png/tl.png differ diff --git a/frontend/public/static/flags/png/tm.png b/frontend/public/static/flags/png/tm.png new file mode 100644 index 000000000..966e3889f Binary files /dev/null and b/frontend/public/static/flags/png/tm.png differ diff --git a/frontend/public/static/flags/png/tn.png b/frontend/public/static/flags/png/tn.png new file mode 100644 index 000000000..0d6a976c8 Binary files /dev/null and b/frontend/public/static/flags/png/tn.png differ diff --git a/frontend/public/static/flags/png/to.png b/frontend/public/static/flags/png/to.png new file mode 100644 index 000000000..ab11e5142 Binary files /dev/null and b/frontend/public/static/flags/png/to.png differ diff --git a/frontend/public/static/flags/png/tr.png b/frontend/public/static/flags/png/tr.png new file mode 100644 index 000000000..2b0614ca8 Binary files /dev/null and b/frontend/public/static/flags/png/tr.png differ diff --git a/frontend/public/static/flags/png/tt.png b/frontend/public/static/flags/png/tt.png new file mode 100644 index 000000000..9b4575fbd Binary files /dev/null and b/frontend/public/static/flags/png/tt.png differ diff --git a/frontend/public/static/flags/png/tv.png b/frontend/public/static/flags/png/tv.png new file mode 100644 index 000000000..306b9ac4e Binary files /dev/null and b/frontend/public/static/flags/png/tv.png differ diff --git a/frontend/public/static/flags/png/tw.png b/frontend/public/static/flags/png/tw.png new file mode 100644 index 000000000..c319e3e25 Binary files /dev/null and b/frontend/public/static/flags/png/tw.png differ diff --git a/frontend/public/static/flags/png/tz.png b/frontend/public/static/flags/png/tz.png new file mode 100644 index 000000000..0c67a2a06 Binary files /dev/null and b/frontend/public/static/flags/png/tz.png differ diff --git a/frontend/public/static/flags/png/ua.png b/frontend/public/static/flags/png/ua.png new file mode 100644 index 000000000..0462a1f0f Binary files /dev/null and b/frontend/public/static/flags/png/ua.png differ diff --git a/frontend/public/static/flags/png/ug.png b/frontend/public/static/flags/png/ug.png new file mode 100644 index 000000000..4d040f83a Binary files /dev/null and b/frontend/public/static/flags/png/ug.png differ diff --git a/frontend/public/static/flags/png/um.png b/frontend/public/static/flags/png/um.png new file mode 100644 index 000000000..ed2d8d0ee Binary files /dev/null and b/frontend/public/static/flags/png/um.png differ diff --git a/frontend/public/static/flags/png/us.png b/frontend/public/static/flags/png/us.png new file mode 100644 index 000000000..4e47eaaf0 Binary files /dev/null and b/frontend/public/static/flags/png/us.png differ diff --git a/frontend/public/static/flags/png/uy.png b/frontend/public/static/flags/png/uy.png new file mode 100644 index 000000000..1ed575d82 Binary files /dev/null and b/frontend/public/static/flags/png/uy.png differ diff --git a/frontend/public/static/flags/png/uz.png b/frontend/public/static/flags/png/uz.png new file mode 100644 index 000000000..97b1a533a Binary files /dev/null and b/frontend/public/static/flags/png/uz.png differ diff --git a/frontend/public/static/flags/png/va.png b/frontend/public/static/flags/png/va.png new file mode 100644 index 000000000..dcdc3a726 Binary files /dev/null and b/frontend/public/static/flags/png/va.png differ diff --git a/frontend/public/static/flags/png/vc.png b/frontend/public/static/flags/png/vc.png new file mode 100644 index 000000000..c6acb93a4 Binary files /dev/null and b/frontend/public/static/flags/png/vc.png differ diff --git a/frontend/public/static/flags/png/ve.png b/frontend/public/static/flags/png/ve.png new file mode 100644 index 000000000..cc80484c1 Binary files /dev/null and b/frontend/public/static/flags/png/ve.png differ diff --git a/frontend/public/static/flags/png/vg.png b/frontend/public/static/flags/png/vg.png new file mode 100644 index 000000000..9a93f26ba Binary files /dev/null and b/frontend/public/static/flags/png/vg.png differ diff --git a/frontend/public/static/flags/png/vi.png b/frontend/public/static/flags/png/vi.png new file mode 100644 index 000000000..e9127a84a Binary files /dev/null and b/frontend/public/static/flags/png/vi.png differ diff --git a/frontend/public/static/flags/png/vn.png b/frontend/public/static/flags/png/vn.png new file mode 100644 index 000000000..cbf65d416 Binary files /dev/null and b/frontend/public/static/flags/png/vn.png differ diff --git a/frontend/public/static/flags/png/vu.png b/frontend/public/static/flags/png/vu.png new file mode 100644 index 000000000..587645673 Binary files /dev/null and b/frontend/public/static/flags/png/vu.png differ diff --git a/frontend/public/static/flags/png/wf.png b/frontend/public/static/flags/png/wf.png new file mode 100644 index 000000000..ffd56d478 Binary files /dev/null and b/frontend/public/static/flags/png/wf.png differ diff --git a/frontend/public/static/flags/png/ws.png b/frontend/public/static/flags/png/ws.png new file mode 100644 index 000000000..18c5b8661 Binary files /dev/null and b/frontend/public/static/flags/png/ws.png differ diff --git a/frontend/public/static/flags/png/xk.png b/frontend/public/static/flags/png/xk.png new file mode 100644 index 000000000..bfe33b104 Binary files /dev/null and b/frontend/public/static/flags/png/xk.png differ diff --git a/frontend/public/static/flags/png/ye.png b/frontend/public/static/flags/png/ye.png new file mode 100644 index 000000000..c094f80ec Binary files /dev/null and b/frontend/public/static/flags/png/ye.png differ diff --git a/frontend/public/static/flags/png/yt.png b/frontend/public/static/flags/png/yt.png new file mode 100644 index 000000000..9bc48abdf Binary files /dev/null and b/frontend/public/static/flags/png/yt.png differ diff --git a/frontend/public/static/flags/png/za.png b/frontend/public/static/flags/png/za.png new file mode 100644 index 000000000..3e7109620 Binary files /dev/null and b/frontend/public/static/flags/png/za.png differ diff --git a/frontend/public/static/flags/png/zm.png b/frontend/public/static/flags/png/zm.png new file mode 100644 index 000000000..b64705861 Binary files /dev/null and b/frontend/public/static/flags/png/zm.png differ diff --git a/frontend/public/static/flags/png/zw.png b/frontend/public/static/flags/png/zw.png new file mode 100644 index 000000000..d9d5cd4f7 Binary files /dev/null and b/frontend/public/static/flags/png/zw.png differ diff --git a/frontend/public/static/flags/pr.svg b/frontend/public/static/flags/pr.svg new file mode 100644 index 000000000..775619c28 --- /dev/null +++ b/frontend/public/static/flags/pr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ps.svg b/frontend/public/static/flags/ps.svg new file mode 100644 index 000000000..c0dfe9c28 --- /dev/null +++ b/frontend/public/static/flags/ps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pt.svg b/frontend/public/static/flags/pt.svg new file mode 100644 index 000000000..88b08160d --- /dev/null +++ b/frontend/public/static/flags/pt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/pw.svg b/frontend/public/static/flags/pw.svg new file mode 100644 index 000000000..802482d37 --- /dev/null +++ b/frontend/public/static/flags/pw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/py.svg b/frontend/public/static/flags/py.svg new file mode 100644 index 000000000..7e41611b8 --- /dev/null +++ b/frontend/public/static/flags/py.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/qa.svg b/frontend/public/static/flags/qa.svg new file mode 100644 index 000000000..b750f072f --- /dev/null +++ b/frontend/public/static/flags/qa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/re.svg b/frontend/public/static/flags/re.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ro.svg b/frontend/public/static/flags/ro.svg new file mode 100644 index 000000000..ccd3c0d0a --- /dev/null +++ b/frontend/public/static/flags/ro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/rs.svg b/frontend/public/static/flags/rs.svg new file mode 100644 index 000000000..a1d6f40f3 --- /dev/null +++ b/frontend/public/static/flags/rs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ru.svg b/frontend/public/static/flags/ru.svg new file mode 100644 index 000000000..6e65fbdaf --- /dev/null +++ b/frontend/public/static/flags/ru.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/rw.svg b/frontend/public/static/flags/rw.svg new file mode 100644 index 000000000..7d2ec0c36 --- /dev/null +++ b/frontend/public/static/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/static/flags/sa.svg b/frontend/public/static/flags/sa.svg new file mode 100644 index 000000000..d9a6ce8b4 --- /dev/null +++ b/frontend/public/static/flags/sa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sb.svg b/frontend/public/static/flags/sb.svg new file mode 100644 index 000000000..b104e17e6 --- /dev/null +++ b/frontend/public/static/flags/sb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sc.svg b/frontend/public/static/flags/sc.svg new file mode 100644 index 000000000..bbc2fc1f8 --- /dev/null +++ b/frontend/public/static/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/static/flags/sd.svg b/frontend/public/static/flags/sd.svg new file mode 100644 index 000000000..a7193e760 --- /dev/null +++ b/frontend/public/static/flags/sd.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/static/flags/se.svg b/frontend/public/static/flags/se.svg new file mode 100644 index 000000000..9223d14cb --- /dev/null +++ b/frontend/public/static/flags/se.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/sg.svg b/frontend/public/static/flags/sg.svg new file mode 100644 index 000000000..d5c6e04c5 --- /dev/null +++ b/frontend/public/static/flags/sg.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/static/flags/sh.svg b/frontend/public/static/flags/sh.svg new file mode 100644 index 000000000..1b8fb4250 --- /dev/null +++ b/frontend/public/static/flags/sh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/si.svg b/frontend/public/static/flags/si.svg new file mode 100644 index 000000000..517b1b188 --- /dev/null +++ b/frontend/public/static/flags/si.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sj.svg b/frontend/public/static/flags/sj.svg new file mode 100644 index 000000000..69120a68b --- /dev/null +++ b/frontend/public/static/flags/sj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sk.svg b/frontend/public/static/flags/sk.svg new file mode 100644 index 000000000..70142ba81 --- /dev/null +++ b/frontend/public/static/flags/sk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sl.svg b/frontend/public/static/flags/sl.svg new file mode 100644 index 000000000..eecb6b5df --- /dev/null +++ b/frontend/public/static/flags/sl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sm.svg b/frontend/public/static/flags/sm.svg new file mode 100644 index 000000000..2cf041dd2 --- /dev/null +++ b/frontend/public/static/flags/sm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sn.svg b/frontend/public/static/flags/sn.svg new file mode 100644 index 000000000..7e3431234 --- /dev/null +++ b/frontend/public/static/flags/sn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/so.svg b/frontend/public/static/flags/so.svg new file mode 100644 index 000000000..3053eee70 --- /dev/null +++ b/frontend/public/static/flags/so.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sr.svg b/frontend/public/static/flags/sr.svg new file mode 100644 index 000000000..721805ed3 --- /dev/null +++ b/frontend/public/static/flags/sr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ss.svg b/frontend/public/static/flags/ss.svg new file mode 100644 index 000000000..71128d76a --- /dev/null +++ b/frontend/public/static/flags/ss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/st.svg b/frontend/public/static/flags/st.svg new file mode 100644 index 000000000..d6d560b22 --- /dev/null +++ b/frontend/public/static/flags/st.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sv.svg b/frontend/public/static/flags/sv.svg new file mode 100644 index 000000000..36cb8695c --- /dev/null +++ b/frontend/public/static/flags/sv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sx.svg b/frontend/public/static/flags/sx.svg new file mode 100644 index 000000000..a51149dd5 --- /dev/null +++ b/frontend/public/static/flags/sx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sy.svg b/frontend/public/static/flags/sy.svg new file mode 100644 index 000000000..d5d8e164d --- /dev/null +++ b/frontend/public/static/flags/sy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/sz.svg b/frontend/public/static/flags/sz.svg new file mode 100644 index 000000000..93e405709 --- /dev/null +++ b/frontend/public/static/flags/sz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tc.svg b/frontend/public/static/flags/tc.svg new file mode 100644 index 000000000..40e4223ce --- /dev/null +++ b/frontend/public/static/flags/tc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/td.svg b/frontend/public/static/flags/td.svg new file mode 100644 index 000000000..29f4fee4c --- /dev/null +++ b/frontend/public/static/flags/td.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tf.svg b/frontend/public/static/flags/tf.svg new file mode 100644 index 000000000..eb3750ae6 --- /dev/null +++ b/frontend/public/static/flags/tf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tg.svg b/frontend/public/static/flags/tg.svg new file mode 100644 index 000000000..629813ab2 --- /dev/null +++ b/frontend/public/static/flags/tg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/th.svg b/frontend/public/static/flags/th.svg new file mode 100644 index 000000000..eb7f78bc7 --- /dev/null +++ b/frontend/public/static/flags/th.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tj.svg b/frontend/public/static/flags/tj.svg new file mode 100644 index 000000000..eaa1c1473 --- /dev/null +++ b/frontend/public/static/flags/tj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tk.svg b/frontend/public/static/flags/tk.svg new file mode 100644 index 000000000..4f92dc189 --- /dev/null +++ b/frontend/public/static/flags/tk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tl.svg b/frontend/public/static/flags/tl.svg new file mode 100644 index 000000000..33dcecb74 --- /dev/null +++ b/frontend/public/static/flags/tl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tm.svg b/frontend/public/static/flags/tm.svg new file mode 100644 index 000000000..4e16348d6 --- /dev/null +++ b/frontend/public/static/flags/tm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tn.svg b/frontend/public/static/flags/tn.svg new file mode 100644 index 000000000..a86d3d4ee --- /dev/null +++ b/frontend/public/static/flags/tn.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/static/flags/to.svg b/frontend/public/static/flags/to.svg new file mode 100644 index 000000000..dcacbaf87 --- /dev/null +++ b/frontend/public/static/flags/to.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tr.svg b/frontend/public/static/flags/tr.svg new file mode 100644 index 000000000..3f676181e --- /dev/null +++ b/frontend/public/static/flags/tr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tt.svg b/frontend/public/static/flags/tt.svg new file mode 100644 index 000000000..b11e506b9 --- /dev/null +++ b/frontend/public/static/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/static/flags/tv.svg b/frontend/public/static/flags/tv.svg new file mode 100644 index 000000000..f88caa5c6 --- /dev/null +++ b/frontend/public/static/flags/tv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tw.svg b/frontend/public/static/flags/tw.svg new file mode 100644 index 000000000..36a2d54e5 --- /dev/null +++ b/frontend/public/static/flags/tw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/tz.svg b/frontend/public/static/flags/tz.svg new file mode 100644 index 000000000..017001f8a --- /dev/null +++ b/frontend/public/static/flags/tz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ua.svg b/frontend/public/static/flags/ua.svg new file mode 100644 index 000000000..c893d0220 --- /dev/null +++ b/frontend/public/static/flags/ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ug.svg b/frontend/public/static/flags/ug.svg new file mode 100644 index 000000000..f26651737 --- /dev/null +++ b/frontend/public/static/flags/ug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/um.svg b/frontend/public/static/flags/um.svg new file mode 100644 index 000000000..9201215ec --- /dev/null +++ b/frontend/public/static/flags/um.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/us.svg b/frontend/public/static/flags/us.svg new file mode 100644 index 000000000..9201215ec --- /dev/null +++ b/frontend/public/static/flags/us.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/uy.svg b/frontend/public/static/flags/uy.svg new file mode 100644 index 000000000..a01a1722c --- /dev/null +++ b/frontend/public/static/flags/uy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/uz.svg b/frontend/public/static/flags/uz.svg new file mode 100644 index 000000000..c2f3bb3b8 --- /dev/null +++ b/frontend/public/static/flags/uz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/va.svg b/frontend/public/static/flags/va.svg new file mode 100644 index 000000000..479895dc8 --- /dev/null +++ b/frontend/public/static/flags/va.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/vc.svg b/frontend/public/static/flags/vc.svg new file mode 100644 index 000000000..ebd16e52c --- /dev/null +++ b/frontend/public/static/flags/vc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ve.svg b/frontend/public/static/flags/ve.svg new file mode 100644 index 000000000..650426774 --- /dev/null +++ b/frontend/public/static/flags/ve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/vg.svg b/frontend/public/static/flags/vg.svg new file mode 100644 index 000000000..ed55d1a64 --- /dev/null +++ b/frontend/public/static/flags/vg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/vi.svg b/frontend/public/static/flags/vi.svg new file mode 100644 index 000000000..be7002dd6 --- /dev/null +++ b/frontend/public/static/flags/vi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/vn.svg b/frontend/public/static/flags/vn.svg new file mode 100644 index 000000000..d0dd98a88 --- /dev/null +++ b/frontend/public/static/flags/vn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/vu.svg b/frontend/public/static/flags/vu.svg new file mode 100644 index 000000000..d75436702 --- /dev/null +++ b/frontend/public/static/flags/vu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/wf.svg b/frontend/public/static/flags/wf.svg new file mode 100644 index 000000000..682f9f055 --- /dev/null +++ b/frontend/public/static/flags/wf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ws.svg b/frontend/public/static/flags/ws.svg new file mode 100644 index 000000000..50716208b --- /dev/null +++ b/frontend/public/static/flags/ws.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/xk.svg b/frontend/public/static/flags/xk.svg new file mode 100644 index 000000000..244c85fd3 --- /dev/null +++ b/frontend/public/static/flags/xk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/ye.svg b/frontend/public/static/flags/ye.svg new file mode 100644 index 000000000..37c3ab3c4 --- /dev/null +++ b/frontend/public/static/flags/ye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/yt.svg b/frontend/public/static/flags/yt.svg new file mode 100644 index 000000000..c76dd719f --- /dev/null +++ b/frontend/public/static/flags/yt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/za.svg b/frontend/public/static/flags/za.svg new file mode 100644 index 000000000..9fd94ac0d --- /dev/null +++ b/frontend/public/static/flags/za.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/zm.svg b/frontend/public/static/flags/zm.svg new file mode 100644 index 000000000..bde9932ba --- /dev/null +++ b/frontend/public/static/flags/zm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/flags/zw.svg b/frontend/public/static/flags/zw.svg new file mode 100644 index 000000000..c38e54878 --- /dev/null +++ b/frontend/public/static/flags/zw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/icons/apple-touch-icon-180x180.png b/frontend/public/static/icons/apple-touch-icon-180x180.png new file mode 100644 index 000000000..bf4dcabbf Binary files /dev/null and b/frontend/public/static/icons/apple-touch-icon-180x180.png differ diff --git a/frontend/public/static/icons/icon-192x192.png b/frontend/public/static/icons/icon-192x192.png new file mode 100644 index 000000000..784f4e640 Binary files /dev/null and b/frontend/public/static/icons/icon-192x192.png differ diff --git a/frontend/public/static/icons/icon-512x512.png b/frontend/public/static/icons/icon-512x512.png new file mode 100644 index 000000000..5e8b55b77 Binary files /dev/null and b/frontend/public/static/icons/icon-512x512.png differ diff --git a/frontend/public/static/icons/icon.png b/frontend/public/static/icons/icon.png new file mode 100644 index 000000000..0c651ac6f Binary files /dev/null and b/frontend/public/static/icons/icon.png differ diff --git a/frontend/public/static/icons/icon.svg b/frontend/public/static/icons/icon.svg new file mode 100644 index 000000000..d30ea9903 --- /dev/null +++ b/frontend/public/static/icons/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/public/static/icons/maskable-icon-512x512.png b/frontend/public/static/icons/maskable-icon-512x512.png new file mode 100644 index 000000000..5e8b55b77 Binary files /dev/null and b/frontend/public/static/icons/maskable-icon-512x512.png differ diff --git a/frontend/public/static/images/github-icon.svg b/frontend/public/static/images/github-icon.svg new file mode 100644 index 000000000..37fa923df --- /dev/null +++ b/frontend/public/static/images/github-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/images/google-icon.svg b/frontend/public/static/images/google-icon.svg new file mode 100644 index 000000000..69342aca9 --- /dev/null +++ b/frontend/public/static/images/google-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/static/images/microsoft-icon.svg b/frontend/public/static/images/microsoft-icon.svg new file mode 100644 index 000000000..cb97e17a3 --- /dev/null +++ b/frontend/public/static/images/microsoft-icon.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/frontend/public/static/images/thumbnail.png b/frontend/public/static/images/thumbnail.png new file mode 100644 index 000000000..2d9dbfe08 Binary files /dev/null and b/frontend/public/static/images/thumbnail.png differ diff --git a/frontend/public/static/integrations/betterstack.svg b/frontend/public/static/integrations/betterstack.svg new file mode 100644 index 000000000..3c56aa7d7 --- /dev/null +++ b/frontend/public/static/integrations/betterstack.svg @@ -0,0 +1,6 @@ + +Better Stack + + + + diff --git a/frontend/public/static/integrations/gleap.svg b/frontend/public/static/integrations/gleap.svg new file mode 100644 index 000000000..db74fb610 --- /dev/null +++ b/frontend/public/static/integrations/gleap.svg @@ -0,0 +1,9 @@ + +Gleap + + + + + + + diff --git a/frontend/public/static/integrations/imado.svg b/frontend/public/static/integrations/imado.svg new file mode 100644 index 000000000..fd2e249d5 --- /dev/null +++ b/frontend/public/static/integrations/imado.svg @@ -0,0 +1,12 @@ + + + Artboard + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/static/integrations/paddle.svg b/frontend/public/static/integrations/paddle.svg new file mode 100644 index 000000000..53260c921 --- /dev/null +++ b/frontend/public/static/integrations/paddle.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/frontend/public/static/integrations/sentry.svg b/frontend/public/static/integrations/sentry.svg new file mode 100644 index 000000000..125866e00 --- /dev/null +++ b/frontend/public/static/integrations/sentry.svg @@ -0,0 +1,6 @@ + +Sentry + + + + diff --git a/frontend/public/static/logo/logo-icon-only.svg b/frontend/public/static/logo/logo-icon-only.svg new file mode 100644 index 000000000..f7ad98278 --- /dev/null +++ b/frontend/public/static/logo/logo-icon-only.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/public/static/logo/logo-large.png b/frontend/public/static/logo/logo-large.png new file mode 100644 index 000000000..ddb158d23 Binary files /dev/null and b/frontend/public/static/logo/logo-large.png differ diff --git a/frontend/public/static/logo/logo-small.png b/frontend/public/static/logo/logo-small.png new file mode 100644 index 000000000..b3a7ef8b1 Binary files /dev/null and b/frontend/public/static/logo/logo-small.png differ diff --git a/frontend/public/static/logo/logo-text-only.svg b/frontend/public/static/logo/logo-text-only.svg new file mode 100644 index 000000000..c7130cb9a --- /dev/null +++ b/frontend/public/static/logo/logo-text-only.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/public/static/logo/logo.png b/frontend/public/static/logo/logo.png new file mode 100644 index 000000000..e91c750cd Binary files /dev/null and b/frontend/public/static/logo/logo.png differ diff --git a/frontend/public/static/logo/logo.svg b/frontend/public/static/logo/logo.svg new file mode 100644 index 000000000..73823b414 --- /dev/null +++ b/frontend/public/static/logo/logo.svg @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/frontend/public/static/screenshots/dark/onboarding.jpg b/frontend/public/static/screenshots/dark/onboarding.jpg new file mode 100644 index 000000000..08e95a86f Binary files /dev/null and b/frontend/public/static/screenshots/dark/onboarding.jpg differ diff --git a/frontend/public/static/screenshots/dark/org-page.jpg b/frontend/public/static/screenshots/dark/org-page.jpg new file mode 100644 index 000000000..dceb9edb4 Binary files /dev/null and b/frontend/public/static/screenshots/dark/org-page.jpg differ diff --git a/frontend/public/static/screenshots/dark/signin-with-cella.jpg b/frontend/public/static/screenshots/dark/signin-with-cella.jpg new file mode 100644 index 000000000..958f29eb2 Binary files /dev/null and b/frontend/public/static/screenshots/dark/signin-with-cella.jpg differ diff --git a/frontend/public/static/screenshots/dark/user-sheet-cella.jpg b/frontend/public/static/screenshots/dark/user-sheet-cella.jpg new file mode 100644 index 000000000..a620673d3 Binary files /dev/null and b/frontend/public/static/screenshots/dark/user-sheet-cella.jpg differ diff --git a/frontend/public/static/screenshots/onboarding.jpg b/frontend/public/static/screenshots/onboarding.jpg new file mode 100644 index 000000000..dc96995c8 Binary files /dev/null and b/frontend/public/static/screenshots/onboarding.jpg differ diff --git a/frontend/public/static/screenshots/org-page.jpg b/frontend/public/static/screenshots/org-page.jpg new file mode 100644 index 000000000..420e1b19b Binary files /dev/null and b/frontend/public/static/screenshots/org-page.jpg differ diff --git a/frontend/public/static/screenshots/signin-with-cella.jpg b/frontend/public/static/screenshots/signin-with-cella.jpg new file mode 100644 index 000000000..c015fede4 Binary files /dev/null and b/frontend/public/static/screenshots/signin-with-cella.jpg differ diff --git a/frontend/public/static/screenshots/user-sheet-cella.jpg b/frontend/public/static/screenshots/user-sheet-cella.jpg new file mode 100644 index 000000000..96748b065 Binary files /dev/null and b/frontend/public/static/screenshots/user-sheet-cella.jpg differ diff --git a/frontend/public/wa-sqlite-async.mjs b/frontend/public/wa-sqlite-async.mjs new file mode 100644 index 000000000..b68fc38bf --- /dev/null +++ b/frontend/public/wa-sqlite-async.mjs @@ -0,0 +1,2713 @@ +var Module = (() => { + var _scriptDir = import.meta.url; + + return (moduleArg = {}) => { + var d = moduleArg, + aa, + ba; + d.ready = new Promise((a, b) => { + aa = a; + ba = b; + }); + var ca = Object.assign({}, d), + da = './this.program', + ea = (a, b) => { + throw b; + }, + fa = 'object' == typeof window, + ha = 'function' == typeof importScripts, + g = '', + ia; + if (fa || ha) + ha ? (g = self.location.href) : 'undefined' != typeof document && document.currentScript && (g = document.currentScript.src), + _scriptDir && (g = _scriptDir), + 0 !== g.indexOf('blob:') ? (g = g.substr(0, g.replace(/[?#].*/, '').lastIndexOf('/') + 1)) : (g = ''), + ha && + (ia = (a) => { + var b = new XMLHttpRequest(); + b.open('GET', a, !1); + b.responseType = 'arraybuffer'; + b.send(null); + return new Uint8Array(b.response); + }); + var ja = d.print || console.log.bind(console), + r = d.printErr || console.error.bind(console); + Object.assign(d, ca); + ca = null; + d.thisProgram && (da = d.thisProgram); + d.quit && (ea = d.quit); + var la; + d.wasmBinary && (la = d.wasmBinary); + 'object' != typeof WebAssembly && u('no native wasm support detected'); + var ma, + v = !1, + na, + w, + x, + z, + oa, + A, + C, + pa, + qa; + function ra() { + var a = ma.buffer; + d.HEAP8 = w = new Int8Array(a); + d.HEAP16 = z = new Int16Array(a); + d.HEAPU8 = x = new Uint8Array(a); + d.HEAPU16 = oa = new Uint16Array(a); + d.HEAP32 = A = new Int32Array(a); + d.HEAPU32 = C = new Uint32Array(a); + d.HEAPF32 = pa = new Float32Array(a); + d.HEAPF64 = qa = new Float64Array(a); + } + var sa = [], + ta = [], + ua = [], + va = []; + function wa() { + var a = d.preRun.shift(); + sa.unshift(a); + } + var xa = 0, + ya = null, + za = null; + function u(a) { + if (d.onAbort) d.onAbort(a); + a = 'Aborted(' + a + ')'; + r(a); + v = !0; + na = 1; + a = new WebAssembly.RuntimeError(a + '. Build with -sASSERTIONS for more info.'); + ba(a); + throw a; + } + var Aa = (a) => a.startsWith('data:application/octet-stream;base64,'), + Ba; + if (d.locateFile) { + if (((Ba = 'wa-sqlite-async.wasm'), !Aa(Ba))) { + var Ca = Ba; + Ba = d.locateFile ? d.locateFile(Ca, g) : g + Ca; + } + } else Ba = new URL('wa-sqlite-async.wasm', import.meta.url).href; + function Da(a) { + if (a == Ba && la) return new Uint8Array(la); + if (ia) return ia(a); + throw 'both async and sync fetching of the wasm failed'; + } + function Ea(a) { + return la || (!fa && !ha) || 'function' != typeof fetch + ? Promise.resolve().then(() => Da(a)) + : fetch(a, { credentials: 'same-origin' }) + .then((b) => { + if (!b.ok) throw "failed to load wasm binary file at '" + a + "'"; + return b.arrayBuffer(); + }) + .catch(() => Da(a)); + } + function Fa(a, b, c) { + return Ea(a) + .then((e) => WebAssembly.instantiate(e, b)) + .then((e) => e) + .then(c, (e) => { + r(`failed to asynchronously prepare wasm: ${e}`); + u(e); + }); + } + function Ga(a, b) { + var c = Ba; + return la || 'function' != typeof WebAssembly.instantiateStreaming || Aa(c) || 'function' != typeof fetch + ? Fa(c, a, b) + : fetch(c, { credentials: 'same-origin' }).then((e) => + WebAssembly.instantiateStreaming(e, a).then(b, (f) => { + r(`wasm streaming compile failed: ${f}`); + r('falling back to ArrayBuffer instantiation'); + return Fa(c, a, b); + }), + ); + } + var D, F; + function Ha(a) { + this.name = 'ExitStatus'; + this.message = `Program terminated with exit(${a})`; + this.status = a; + } + var Ia = (a) => { + while (0 < a.length) a.shift()(d); + }; + function H(a, b = 'i8') { + b.endsWith('*') && (b = '*'); + switch (b) { + case 'i1': + return w[a >> 0]; + case 'i8': + return w[a >> 0]; + case 'i16': + return z[a >> 1]; + case 'i32': + return A[a >> 2]; + case 'i64': + u('to do getValue(i64) use WASM_BIGINT'); + case 'float': + return pa[a >> 2]; + case 'double': + return qa[a >> 3]; + case '*': + return C[a >> 2]; + default: + u(`invalid type for getValue: ${b}`); + } + } + var Ja = d.noExitRuntime || !0; + function J(a, b, c = 'i8') { + c.endsWith('*') && (c = '*'); + switch (c) { + case 'i1': + w[a >> 0] = b; + break; + case 'i8': + w[a >> 0] = b; + break; + case 'i16': + z[a >> 1] = b; + break; + case 'i32': + A[a >> 2] = b; + break; + case 'i64': + u('to do setValue(i64) use WASM_BIGINT'); + case 'float': + pa[a >> 2] = b; + break; + case 'double': + qa[a >> 3] = b; + break; + case '*': + C[a >> 2] = b; + break; + default: + u(`invalid type for setValue: ${c}`); + } + } + var Ka = 'undefined' != typeof TextDecoder ? new TextDecoder('utf8') : void 0, + K = (a, b, c) => { + var e = b + c; + for (c = b; a[c] && !(c >= e); ) ++c; + if (16 < c - b && a.buffer && Ka) return Ka.decode(a.subarray(b, c)); + for (e = ''; b < c; ) { + var f = a[b++]; + if (f & 128) { + var h = a[b++] & 63; + if (192 == (f & 224)) e += String.fromCharCode(((f & 31) << 6) | h); + else { + var k = a[b++] & 63; + f = 224 == (f & 240) ? ((f & 15) << 12) | (h << 6) | k : ((f & 7) << 18) | (h << 12) | (k << 6) | (a[b++] & 63); + 65536 > f ? (e += String.fromCharCode(f)) : ((f -= 65536), (e += String.fromCharCode(55296 | (f >> 10), 56320 | (f & 1023)))); + } + } else e += String.fromCharCode(f); + } + return e; + }, + La = (a, b) => { + for (var c = 0, e = a.length - 1; 0 <= e; e--) { + var f = a[e]; + '.' === f ? a.splice(e, 1) : '..' === f ? (a.splice(e, 1), c++) : c && (a.splice(e, 1), c--); + } + if (b) for (; c; c--) a.unshift('..'); + return a; + }, + M = (a) => { + var b = '/' === a.charAt(0), + c = '/' === a.substr(-1); + (a = La( + a.split('/').filter((e) => !!e), + !b, + ).join('/')) || + b || + (a = '.'); + a && c && (a += '/'); + return (b ? '/' : '') + a; + }, + Ma = (a) => { + var b = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/.exec(a).slice(1); + a = b[0]; + b = b[1]; + if (!a && !b) return '.'; + b && (b = b.substr(0, b.length - 1)); + return a + b; + }, + Na = (a) => { + if ('/' === a) return '/'; + a = M(a); + a = a.replace(/\/$/, ''); + var b = a.lastIndexOf('/'); + return -1 === b ? a : a.substr(b + 1); + }, + Oa = () => { + if ('object' == typeof crypto && 'function' == typeof crypto.getRandomValues) return (a) => crypto.getRandomValues(a); + u('initRandomDevice'); + }, + Pa = (a) => (Pa = Oa())(a); + function Qa() { + for (var a = '', b = !1, c = arguments.length - 1; -1 <= c && !b; c--) { + b = 0 <= c ? arguments[c] : '/'; + if ('string' != typeof b) throw new TypeError('Arguments to path.resolve must be strings'); + if (!b) return ''; + a = b + '/' + a; + b = '/' === b.charAt(0); + } + a = La( + a.split('/').filter((e) => !!e), + !b, + ).join('/'); + return (b ? '/' : '') + a || '.'; + } + var Ra = [], + Sa = (a) => { + for (var b = 0, c = 0; c < a.length; ++c) { + var e = a.charCodeAt(c); + 127 >= e ? b++ : 2047 >= e ? (b += 2) : 55296 <= e && 57343 >= e ? ((b += 4), ++c) : (b += 3); + } + return b; + }, + Ta = (a, b, c, e) => { + if (!(0 < e)) return 0; + var f = c; + e = c + e - 1; + for (var h = 0; h < a.length; ++h) { + var k = a.charCodeAt(h); + if (55296 <= k && 57343 >= k) { + var n = a.charCodeAt(++h); + k = (65536 + ((k & 1023) << 10)) | (n & 1023); + } + if (127 >= k) { + if (c >= e) break; + b[c++] = k; + } else { + if (2047 >= k) { + if (c + 1 >= e) break; + b[c++] = 192 | (k >> 6); + } else { + if (65535 >= k) { + if (c + 2 >= e) break; + b[c++] = 224 | (k >> 12); + } else { + if (c + 3 >= e) break; + b[c++] = 240 | (k >> 18); + b[c++] = 128 | ((k >> 12) & 63); + } + b[c++] = 128 | ((k >> 6) & 63); + } + b[c++] = 128 | (k & 63); + } + } + b[c] = 0; + return c - f; + }; + function Ua(a, b, c) { + c = Array(0 < c ? c : Sa(a) + 1); + a = Ta(a, c, 0, c.length); + b && (c.length = a); + return c; + } + var Va = []; + function Wa(a, b) { + Va[a] = { input: [], Cf: [], Nf: b }; + Xa(a, Ya); + } + var Ya = { + open(a) { + var b = Va[a.node.Qf]; + if (!b) throw new N(43); + a.Df = b; + a.seekable = !1; + }, + close(a) { + a.Df.Nf.Sf(a.Df); + }, + Sf(a) { + a.Df.Nf.Sf(a.Df); + }, + read(a, b, c, e) { + if (!a.Df || !a.Df.Nf.hg) throw new N(60); + for (var f = 0, h = 0; h < e; h++) { + try { + var k = a.Df.Nf.hg(a.Df); + } catch (n) { + throw new N(29); + } + if (void 0 === k && 0 === f) throw new N(6); + if (null === k || void 0 === k) break; + f++; + b[c + h] = k; + } + f && (a.node.timestamp = Date.now()); + return f; + }, + write(a, b, c, e) { + if (!a.Df || !a.Df.Nf.bg) throw new N(60); + try { + for (var f = 0; f < e; f++) a.Df.Nf.bg(a.Df, b[c + f]); + } catch (h) { + throw new N(29); + } + e && (a.node.timestamp = Date.now()); + return f; + }, + }, + Za = { + hg() { + a: { + if (!Ra.length) { + var a = null; + 'undefined' != typeof window && 'function' == typeof window.prompt + ? ((a = window.prompt('Input: ')), null !== a && (a += '\n')) + : 'function' == typeof readline && ((a = readline()), null !== a && (a += '\n')); + if (!a) { + a = null; + break a; + } + Ra = Ua(a, !0); + } + a = Ra.shift(); + } + return a; + }, + bg(a, b) { + null === b || 10 === b ? (ja(K(a.Cf, 0)), (a.Cf = [])) : 0 != b && a.Cf.push(b); + }, + Sf(a) { + a.Cf && 0 < a.Cf.length && (ja(K(a.Cf, 0)), (a.Cf = [])); + }, + Ig() { + return { + Eg: 25856, + Gg: 5, + Dg: 191, + Fg: 35387, + Cg: [3, 28, 127, 21, 4, 0, 1, 0, 17, 19, 26, 0, 18, 15, 23, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }; + }, + Jg() { + return 0; + }, + Kg() { + return [24, 80]; + }, + }, + $a = { + bg(a, b) { + null === b || 10 === b ? (r(K(a.Cf, 0)), (a.Cf = [])) : 0 != b && a.Cf.push(b); + }, + Sf(a) { + a.Cf && 0 < a.Cf.length && (r(K(a.Cf, 0)), (a.Cf = [])); + }, + }; + function ab(a, b) { + var c = a.yf ? a.yf.length : 0; + c >= b || + ((b = Math.max(b, (c * (1048576 > c ? 2 : 1.125)) >>> 0)), + 0 != c && (b = Math.max(b, 256)), + (c = a.yf), + (a.yf = new Uint8Array(b)), + 0 < a.Af && a.yf.set(c.subarray(0, a.Af), 0)); + } + var O = { + Gf: null, + Ff() { + return O.createNode(null, '/', 16895, 0); + }, + createNode(a, b, c, e) { + if (24576 === (c & 61440) || 4096 === (c & 61440)) throw new N(63); + O.Gf || + (O.Gf = { + dir: { + node: { Ef: O.wf.Ef, Bf: O.wf.Bf, Of: O.wf.Of, Tf: O.wf.Tf, lg: O.wf.lg, Yf: O.wf.Yf, Wf: O.wf.Wf, kg: O.wf.kg, Xf: O.wf.Xf }, + stream: { Kf: O.xf.Kf }, + }, + file: { + node: { Ef: O.wf.Ef, Bf: O.wf.Bf }, + stream: { Kf: O.xf.Kf, read: O.xf.read, write: O.xf.write, eg: O.xf.eg, Uf: O.xf.Uf, Vf: O.xf.Vf }, + }, + link: { node: { Ef: O.wf.Ef, Bf: O.wf.Bf, Rf: O.wf.Rf }, stream: {} }, + fg: { node: { Ef: O.wf.Ef, Bf: O.wf.Bf }, stream: bb }, + }); + c = cb(a, b, c, e); + P(c.mode) + ? ((c.wf = O.Gf.dir.node), (c.xf = O.Gf.dir.stream), (c.yf = {})) + : 32768 === (c.mode & 61440) + ? ((c.wf = O.Gf.file.node), (c.xf = O.Gf.file.stream), (c.Af = 0), (c.yf = null)) + : 40960 === (c.mode & 61440) + ? ((c.wf = O.Gf.link.node), (c.xf = O.Gf.link.stream)) + : 8192 === (c.mode & 61440) && ((c.wf = O.Gf.fg.node), (c.xf = O.Gf.fg.stream)); + c.timestamp = Date.now(); + a && ((a.yf[b] = c), (a.timestamp = c.timestamp)); + return c; + }, + Hg(a) { + return a.yf ? (a.yf.subarray ? a.yf.subarray(0, a.Af) : new Uint8Array(a.yf)) : new Uint8Array(0); + }, + wf: { + Ef(a) { + var b = {}; + b.rg = 8192 === (a.mode & 61440) ? a.id : 1; + b.ig = a.id; + b.mode = a.mode; + b.xg = 1; + b.uid = 0; + b.ug = 0; + b.Qf = a.Qf; + P(a.mode) + ? (b.size = 4096) + : 32768 === (a.mode & 61440) + ? (b.size = a.Af) + : 40960 === (a.mode & 61440) + ? (b.size = a.link.length) + : (b.size = 0); + b.ng = new Date(a.timestamp); + b.wg = new Date(a.timestamp); + b.qg = new Date(a.timestamp); + b.og = 4096; + b.pg = Math.ceil(b.size / b.og); + return b; + }, + Bf(a, b) { + void 0 !== b.mode && (a.mode = b.mode); + void 0 !== b.timestamp && (a.timestamp = b.timestamp); + if (void 0 !== b.size && ((b = b.size), a.Af != b)) + if (0 == b) (a.yf = null), (a.Af = 0); + else { + var c = a.yf; + a.yf = new Uint8Array(b); + c && a.yf.set(c.subarray(0, Math.min(b, a.Af))); + a.Af = b; + } + }, + Of() { + throw db[44]; + }, + Tf(a, b, c, e) { + return O.createNode(a, b, c, e); + }, + lg(a, b, c) { + if (P(a.mode)) { + try { + var e = eb(b, c); + } catch (h) {} + if (e) for (var f in e.yf) throw new N(55); + } + delete a.parent.yf[a.name]; + a.parent.timestamp = Date.now(); + a.name = c; + b.yf[c] = a; + b.timestamp = a.parent.timestamp; + a.parent = b; + }, + Yf(a, b) { + delete a.yf[b]; + a.timestamp = Date.now(); + }, + Wf(a, b) { + var c = eb(a, b), + e; + for (e in c.yf) throw new N(55); + delete a.yf[b]; + a.timestamp = Date.now(); + }, + kg(a) { + var b = ['.', '..'], + c; + for (c in a.yf) a.yf.hasOwnProperty(c) && b.push(c); + return b; + }, + Xf(a, b, c) { + a = O.createNode(a, b, 41471, 0); + a.link = c; + return a; + }, + Rf(a) { + if (40960 !== (a.mode & 61440)) throw new N(28); + return a.link; + }, + }, + xf: { + read(a, b, c, e, f) { + var h = a.node.yf; + if (f >= a.node.Af) return 0; + a = Math.min(a.node.Af - f, e); + if (8 < a && h.subarray) b.set(h.subarray(f, f + a), c); + else for (e = 0; e < a; e++) b[c + e] = h[f + e]; + return a; + }, + write(a, b, c, e, f, h) { + b.buffer === w.buffer && (h = !1); + if (!e) return 0; + a = a.node; + a.timestamp = Date.now(); + if (b.subarray && (!a.yf || a.yf.subarray)) { + if (h) return (a.yf = b.subarray(c, c + e)), (a.Af = e); + if (0 === a.Af && 0 === f) return (a.yf = b.slice(c, c + e)), (a.Af = e); + if (f + e <= a.Af) return a.yf.set(b.subarray(c, c + e), f), e; + } + ab(a, f + e); + if (a.yf.subarray && b.subarray) a.yf.set(b.subarray(c, c + e), f); + else for (h = 0; h < e; h++) a.yf[f + h] = b[c + h]; + a.Af = Math.max(a.Af, f + e); + return e; + }, + Kf(a, b, c) { + 1 === c ? (b += a.position) : 2 === c && 32768 === (a.node.mode & 61440) && (b += a.node.Af); + if (0 > b) throw new N(28); + return b; + }, + eg(a, b, c) { + ab(a.node, b + c); + a.node.Af = Math.max(a.node.Af, b + c); + }, + Uf(a, b, c, e, f) { + if (32768 !== (a.node.mode & 61440)) throw new N(43); + a = a.node.yf; + if (f & 2 || a.buffer !== w.buffer) { + if (0 < c || c + b < a.length) a.subarray ? (a = a.subarray(c, c + b)) : (a = Array.prototype.slice.call(a, c, c + b)); + c = !0; + b = 65536 * Math.ceil(b / 65536); + (f = fb(65536, b)) ? (x.fill(0, f, f + b), (b = f)) : (b = 0); + if (!b) throw new N(48); + w.set(a, b); + } else (c = !1), (b = a.byteOffset); + return { yg: b, mg: c }; + }, + Vf(a, b, c, e) { + O.xf.write(a, b, 0, e, c, !1); + return 0; + }, + }, + }, + gb = (a, b) => { + var c = 0; + a && (c |= 365); + b && (c |= 146); + return c; + }, + hb = null, + ib = {}, + jb = [], + kb = 1, + Q = null, + lb = !0, + N = null, + db = {}; + function R(a, b = {}) { + a = Qa(a); + if (!a) return { path: '', node: null }; + b = Object.assign({ gg: !0, cg: 0 }, b); + if (8 < b.cg) throw new N(32); + a = a.split('/').filter((k) => !!k); + for (var c = hb, e = '/', f = 0; f < a.length; f++) { + var h = f === a.length - 1; + if (h && b.parent) break; + c = eb(c, a[f]); + e = M(e + '/' + a[f]); + c.Lf && (!h || (h && b.gg)) && (c = c.Lf.root); + if (!h || b.Jf) + for (h = 0; 40960 === (c.mode & 61440); ) + if (((c = mb(e)), (e = Qa(Ma(e), c)), (c = R(e, { cg: b.cg + 1 }).node), 40 < h++)) throw new N(32); + } + return { path: e, node: c }; + } + function nb(a) { + for (var b; ; ) { + if (a === a.parent) return (a = a.Ff.jg), b ? ('/' !== a[a.length - 1] ? `${a}/${b}` : a + b) : a; + b = b ? `${a.name}/${b}` : a.name; + a = a.parent; + } + } + function ob(a, b) { + for (var c = 0, e = 0; e < b.length; e++) c = ((c << 5) - c + b.charCodeAt(e)) | 0; + return ((a + c) >>> 0) % Q.length; + } + function pb(a) { + var b = ob(a.parent.id, a.name); + if (Q[b] === a) Q[b] = a.Mf; + else + for (b = Q[b]; b; ) { + if (b.Mf === a) { + b.Mf = a.Mf; + break; + } + b = b.Mf; + } + } + function eb(a, b) { + var c; + if ((c = (c = qb(a, 'x')) ? c : a.wf.Of ? 0 : 2)) throw new N(c, a); + for (c = Q[ob(a.id, b)]; c; c = c.Mf) { + var e = c.name; + if (c.parent.id === a.id && e === b) return c; + } + return a.wf.Of(a, b); + } + function cb(a, b, c, e) { + a = new rb(a, b, c, e); + b = ob(a.parent.id, a.name); + a.Mf = Q[b]; + return (Q[b] = a); + } + function P(a) { + return 16384 === (a & 61440); + } + function sb(a) { + var b = ['r', 'w', 'rw'][a & 3]; + a & 512 && (b += 'w'); + return b; + } + function qb(a, b) { + if (lb) return 0; + if (!b.includes('r') || a.mode & 292) { + if ((b.includes('w') && !(a.mode & 146)) || (b.includes('x') && !(a.mode & 73))) return 2; + } else return 2; + return 0; + } + function tb(a, b) { + try { + return eb(a, b), 20; + } catch (c) {} + return qb(a, 'wx'); + } + function ub(a, b, c) { + try { + var e = eb(a, b); + } catch (f) { + return f.zf; + } + if ((a = qb(a, 'wx'))) return a; + if (c) { + if (!P(e.mode)) return 54; + if (e === e.parent || '/' === nb(e)) return 10; + } else if (P(e.mode)) return 31; + return 0; + } + function vb() { + for (var a = 0; 4096 >= a; a++) if (!jb[a]) return a; + throw new N(33); + } + function S(a) { + a = jb[a]; + if (!a) throw new N(8); + return a; + } + function wb(a, b = -1) { + xb || + ((xb = function () { + this.Zf = {}; + }), + (xb.prototype = {}), + Object.defineProperties(xb.prototype, { + object: { + get() { + return this.node; + }, + set(c) { + this.node = c; + }, + }, + flags: { + get() { + return this.Zf.flags; + }, + set(c) { + this.Zf.flags = c; + }, + }, + position: { + get() { + return this.Zf.position; + }, + set(c) { + this.Zf.position = c; + }, + }, + })); + a = Object.assign(new xb(), a); + -1 == b && (b = vb()); + a.Hf = b; + return (jb[b] = a); + } + var bb = { + open(a) { + a.xf = ib[a.node.Qf].xf; + a.xf.open && a.xf.open(a); + }, + Kf() { + throw new N(70); + }, + }; + function Xa(a, b) { + ib[a] = { xf: b }; + } + function yb(a, b) { + var c = '/' === b, + e = !b; + if (c && hb) throw new N(10); + if (!c && !e) { + var f = R(b, { gg: !1 }); + b = f.path; + f = f.node; + if (f.Lf) throw new N(10); + if (!P(f.mode)) throw new N(54); + } + b = { type: a, Mg: {}, jg: b, vg: [] }; + a = a.Ff(b); + a.Ff = b; + b.root = a; + c ? (hb = a) : f && ((f.Lf = b), f.Ff && f.Ff.vg.push(b)); + } + function zb(a, b, c) { + var e = R(a, { parent: !0 }).node; + a = Na(a); + if (!a || '.' === a || '..' === a) throw new N(28); + var f = tb(e, a); + if (f) throw new N(f); + if (!e.wf.Tf) throw new N(63); + return e.wf.Tf(e, a, b, c); + } + function T(a, b) { + return zb(a, ((void 0 !== b ? b : 511) & 1023) | 16384, 0); + } + function Ab(a, b, c) { + 'undefined' == typeof c && ((c = b), (b = 438)); + zb(a, b | 8192, c); + } + function Bb(a, b) { + if (!Qa(a)) throw new N(44); + var c = R(b, { parent: !0 }).node; + if (!c) throw new N(44); + b = Na(b); + var e = tb(c, b); + if (e) throw new N(e); + if (!c.wf.Xf) throw new N(63); + c.wf.Xf(c, b, a); + } + function Cb(a) { + var b = R(a, { parent: !0 }).node; + a = Na(a); + var c = eb(b, a), + e = ub(b, a, !0); + if (e) throw new N(e); + if (!b.wf.Wf) throw new N(63); + if (c.Lf) throw new N(10); + b.wf.Wf(b, a); + pb(c); + } + function mb(a) { + a = R(a).node; + if (!a) throw new N(44); + if (!a.wf.Rf) throw new N(28); + return Qa(nb(a.parent), a.wf.Rf(a)); + } + function Db(a, b) { + a = R(a, { Jf: !b }).node; + if (!a) throw new N(44); + if (!a.wf.Ef) throw new N(63); + return a.wf.Ef(a); + } + function Eb(a) { + return Db(a, !0); + } + function Fb(a, b) { + a = 'string' == typeof a ? R(a, { Jf: !0 }).node : a; + if (!a.wf.Bf) throw new N(63); + a.wf.Bf(a, { mode: (b & 4095) | (a.mode & -4096), timestamp: Date.now() }); + } + function Gb(a, b) { + if (0 > b) throw new N(28); + a = 'string' == typeof a ? R(a, { Jf: !0 }).node : a; + if (!a.wf.Bf) throw new N(63); + if (P(a.mode)) throw new N(31); + if (32768 !== (a.mode & 61440)) throw new N(28); + var c = qb(a, 'w'); + if (c) throw new N(c); + a.wf.Bf(a, { size: b, timestamp: Date.now() }); + } + function Hb(a, b, c) { + if ('' === a) throw new N(44); + if ('string' == typeof b) { + var e = { r: 0, 'r+': 2, w: 577, 'w+': 578, a: 1089, 'a+': 1090 }[b]; + if ('undefined' == typeof e) throw Error(`Unknown file open mode: ${b}`); + b = e; + } + c = b & 64 ? (('undefined' == typeof c ? 438 : c) & 4095) | 32768 : 0; + if ('object' == typeof a) var f = a; + else { + a = M(a); + try { + f = R(a, { Jf: !(b & 131072) }).node; + } catch (h) {} + } + e = !1; + if (b & 64) + if (f) { + if (b & 128) throw new N(20); + } else (f = zb(a, c, 0)), (e = !0); + if (!f) throw new N(44); + 8192 === (f.mode & 61440) && (b &= -513); + if (b & 65536 && !P(f.mode)) throw new N(54); + if (!e && (c = f ? (40960 === (f.mode & 61440) ? 32 : P(f.mode) && ('r' !== sb(b) || b & 512) ? 31 : qb(f, sb(b))) : 44)) throw new N(c); + b & 512 && !e && Gb(f, 0); + b &= -131713; + f = wb({ node: f, path: nb(f), flags: b, seekable: !0, position: 0, xf: f.xf, Bg: [], error: !1 }); + f.xf.open && f.xf.open(f); + !d.logReadFiles || b & 1 || (Ib || (Ib = {}), a in Ib || (Ib[a] = 1)); + return f; + } + function Jb(a, b, c) { + if (null === a.Hf) throw new N(8); + if (!a.seekable || !a.xf.Kf) throw new N(70); + if (0 != c && 1 != c && 2 != c) throw new N(28); + a.position = a.xf.Kf(a, b, c); + a.Bg = []; + } + function Kb() { + N || + ((N = function (a, b) { + this.name = 'ErrnoError'; + this.node = b; + this.zg = function (c) { + this.zf = c; + }; + this.zg(a); + this.message = 'FS error'; + }), + (N.prototype = Error()), + (N.prototype.constructor = N), + [44].forEach((a) => { + db[a] = new N(a); + db[a].stack = ''; + })); + } + var Lb; + function Mb(a, b, c) { + a = M('/dev/' + a); + var e = gb(!!b, !!c); + Nb || (Nb = 64); + var f = (Nb++ << 8) | 0; + Xa(f, { + open(h) { + h.seekable = !1; + }, + close() { + c && c.buffer && c.buffer.length && c(10); + }, + read(h, k, n, l) { + for (var m = 0, q = 0; q < l; q++) { + try { + var p = b(); + } catch (t) { + throw new N(29); + } + if (void 0 === p && 0 === m) throw new N(6); + if (null === p || void 0 === p) break; + m++; + k[n + q] = p; + } + m && (h.node.timestamp = Date.now()); + return m; + }, + write(h, k, n, l) { + for (var m = 0; m < l; m++) + try { + c(k[n + m]); + } catch (q) { + throw new N(29); + } + l && (h.node.timestamp = Date.now()); + return m; + }, + }); + Ab(a, e, f); + } + var Nb, + U = {}, + xb, + Ib; + function Ob(a, b, c) { + if ('/' === b.charAt(0)) return b; + a = -100 === a ? '/' : S(a).path; + if (0 == b.length) { + if (!c) throw new N(44); + return a; + } + return M(a + '/' + b); + } + function Pb(a, b, c) { + try { + var e = a(b); + } catch (h) { + if (h && h.node && M(b) !== M(nb(h.node))) return -54; + throw h; + } + A[c >> 2] = e.rg; + A[(c + 4) >> 2] = e.mode; + C[(c + 8) >> 2] = e.xg; + A[(c + 12) >> 2] = e.uid; + A[(c + 16) >> 2] = e.ug; + A[(c + 20) >> 2] = e.Qf; + F = [ + e.size >>> 0, + ((D = e.size), 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(c + 24) >> 2] = F[0]; + A[(c + 28) >> 2] = F[1]; + A[(c + 32) >> 2] = 4096; + A[(c + 36) >> 2] = e.pg; + a = e.ng.getTime(); + b = e.wg.getTime(); + var f = e.qg.getTime(); + F = [ + Math.floor(a / 1e3) >>> 0, + ((D = Math.floor(a / 1e3)), + 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(c + 40) >> 2] = F[0]; + A[(c + 44) >> 2] = F[1]; + C[(c + 48) >> 2] = (a % 1e3) * 1e3; + F = [ + Math.floor(b / 1e3) >>> 0, + ((D = Math.floor(b / 1e3)), + 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(c + 56) >> 2] = F[0]; + A[(c + 60) >> 2] = F[1]; + C[(c + 64) >> 2] = (b % 1e3) * 1e3; + F = [ + Math.floor(f / 1e3) >>> 0, + ((D = Math.floor(f / 1e3)), + 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(c + 72) >> 2] = F[0]; + A[(c + 76) >> 2] = F[1]; + C[(c + 80) >> 2] = (f % 1e3) * 1e3; + F = [ + e.ig >>> 0, + ((D = e.ig), 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(c + 88) >> 2] = F[0]; + A[(c + 92) >> 2] = F[1]; + return 0; + } + var Qb = void 0; + function Rb() { + var a = A[+Qb >> 2]; + Qb += 4; + return a; + } + var Sb = (a, b) => ((b + 2097152) >>> 0 < 4194305 - !!a ? (a >>> 0) + 4294967296 * b : Number.NaN), + Tb = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335], + Ub = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334], + Wb = (a) => { + var b = Sa(a) + 1, + c = Vb(b); + c && Ta(a, x, c, b); + return c; + }, + Xb = {}, + Zb = () => { + if (!Yb) { + var a = { + USER: 'web_user', + LOGNAME: 'web_user', + PATH: '/', + PWD: '/', + HOME: '/home/web_user', + LANG: (('object' == typeof navigator && navigator.languages && navigator.languages[0]) || 'C').replace('-', '_') + '.UTF-8', + _: da || './this.program', + }, + b; + for (b in Xb) void 0 === Xb[b] ? delete a[b] : (a[b] = Xb[b]); + var c = []; + for (b in a) c.push(`${b}=${a[b]}`); + Yb = c; + } + return Yb; + }, + Yb; + function $b() {} + function ac() {} + function bc() {} + function cc() {} + function dc() {} + function ec() {} + function fc() {} + function gc() {} + function hc() {} + function ic() {} + function jc() {} + function kc() {} + function lc() {} + function mc() {} + function nc() {} + function oc() {} + function pc() {} + function qc() {} + function rc() {} + function sc() {} + function tc() {} + function uc() {} + function vc() {} + function wc() {} + function xc() {} + function yc() {} + function zc() {} + function Ac() {} + function Bc() {} + function Cc() {} + function Dc() {} + function Ec() {} + function Fc() {} + function Gc() {} + function Hc() {} + function Ic() {} + function Jc() {} + function Kc() {} + function Lc() {} + var Mc = 0, + Nc = (a) => { + na = a; + if (!(Ja || 0 < Mc)) { + if (d.onExit) d.onExit(a); + v = !0; + } + ea(a, new Ha(a)); + }, + Oc = (a) => { + a instanceof Ha || 'unwind' == a || ea(1, a); + }, + Pc = (a) => { + try { + a(); + } catch (b) { + u(b); + } + }; + function Qc() { + var a = V, + b = {}, + c; + for (c in a) + ((e) => { + var f = a[e]; + b[e] = + 'function' == typeof f + ? () => { + Rc.push(e); + try { + return f.apply(null, arguments); + } finally { + v || (Rc.pop() === e || u(), W && 1 === Y && 0 === Rc.length && ((Y = 0), Pc(Sc), 'undefined' != typeof Fibers && Fibers.Ng())); + } + } + : f; + })(c); + return b; + } + var Y = 0, + W = null, + Tc = 0, + Rc = [], + Uc = {}, + Vc = {}, + Wc = 0, + Xc = null, + Yc = []; + function Zc() { + return new Promise((a, b) => { + Xc = { resolve: a, reject: b }; + }); + } + function $c() { + var a = Vb(16396), + b = a + 12; + C[a >> 2] = b; + C[(a + 4) >> 2] = b + 16384; + b = Rc[0]; + var c = Uc[b]; + void 0 === c && ((c = Wc++), (Uc[b] = c), (Vc[c] = b)); + A[(a + 8) >> 2] = c; + return a; + } + function ad(a) { + if (!v) { + if (0 === Y) { + var b = !1, + c = !1; + a((e = 0) => { + if (!v && ((Tc = e), (b = !0), c)) { + Y = 2; + Pc(() => bd(W)); + 'undefined' != typeof Browser && Browser.ag.tg && Browser.ag.resume(); + e = !1; + try { + var f = (0, V[Vc[A[(W + 8) >> 2]]])(); + } catch (n) { + (f = n), (e = !0); + } + var h = !1; + if (!W) { + var k = Xc; + k && ((Xc = null), (e ? k.reject : k.resolve)(f), (h = !0)); + } + if (e && !h) throw f; + } + }); + c = !0; + b || ((Y = 1), (W = $c()), 'undefined' != typeof Browser && Browser.ag.tg && Browser.ag.pause(), Pc(() => cd(W))); + } else + 2 === Y + ? ((Y = 0), + Pc(dd), + ed(W), + (W = null), + Yc.forEach((e) => { + if (!v) + try { + if ((e(), !(Ja || 0 < Mc))) + try { + (na = e = na), Nc(e); + } catch (f) { + Oc(f); + } + } catch (f) { + Oc(f); + } + })) + : u(`invalid state: ${Y}`); + return Tc; + } + } + function fd(a) { + return ad((b) => { + a().then(b); + }); + } + var gd = {}, + hd, + jd, + kd = [], + Z = (a, b, c, e, f) => { + function h(p) { + --Mc; + 0 !== l && ld(l); + return 'string' === b ? (p ? K(x, p) : '') : 'boolean' === b ? !!p : p; + } + var k = { + string: (p) => { + var t = 0; + if (null !== p && void 0 !== p && 0 !== p) { + t = Sa(p) + 1; + var y = md(t); + Ta(p, x, y, t); + t = y; + } + return t; + }, + array: (p) => { + var t = md(p.length); + w.set(p, t); + return t; + }, + }; + a = d['_' + a]; + var n = [], + l = 0; + if (e) + for (var m = 0; m < e.length; m++) { + var q = k[c[m]]; + q ? (0 === l && (l = nd()), (n[m] = q(e[m]))) : (n[m] = e[m]); + } + c = W; + e = a.apply(null, n); + f = f && f.async; + Mc += 1; + if (W != c) return Zc().then(h); + e = h(e); + return f ? Promise.resolve(e) : e; + }, + od = 'undefined' != typeof TextDecoder ? new TextDecoder('utf-16le') : void 0; + function rb(a, b, c, e) { + a || (a = this); + this.parent = a; + this.Ff = a.Ff; + this.Lf = null; + this.id = kb++; + this.name = b; + this.mode = c; + this.wf = {}; + this.xf = {}; + this.Qf = e; + } + Object.defineProperties(rb.prototype, { + read: { + get: function () { + return 365 === (this.mode & 365); + }, + set: function (a) { + a ? (this.mode |= 365) : (this.mode &= -366); + }, + }, + write: { + get: function () { + return 146 === (this.mode & 146); + }, + set: function (a) { + a ? (this.mode |= 146) : (this.mode &= -147); + }, + }, + }); + Kb(); + Q = Array(4096); + yb(O, '/'); + T('/tmp'); + T('/home'); + T('/home/web_user'); + (() => { + T('/dev'); + Xa(259, { read: () => 0, write: (e, f, h, k) => k }); + Ab('/dev/null', 259); + Wa(1280, Za); + Wa(1536, $a); + Ab('/dev/tty', 1280); + Ab('/dev/tty1', 1536); + var a = new Uint8Array(1024), + b = 0, + c = () => { + 0 === b && (b = Pa(a).byteLength); + return a[--b]; + }; + Mb('random', c); + Mb('urandom', c); + T('/dev/shm'); + T('/dev/shm/tmp'); + })(); + (() => { + T('/proc'); + var a = T('/proc/self'); + T('/proc/self/fd'); + yb( + { + Ff() { + var b = cb(a, 'fd', 16895, 73); + b.wf = { + Of(c, e) { + var f = S(+e); + c = { parent: null, Ff: { jg: 'fake' }, wf: { Rf: () => f.path } }; + return (c.parent = c); + }, + }; + return b; + }, + }, + '/proc/self/fd', + ); + })(); + (() => { + const a = new Map(); + d.setAuthorizer = (b, c, e) => { + c ? a.set(b, { f: c, dg: e }) : a.delete(b); + return Z('set_authorizer', 'number', ['number'], [b]); + }; + $b = (b, c, e, f, h, k) => { + if (a.has(b)) { + const { f: n, dg: l } = a.get(b); + return n(l, c, e ? (e ? K(x, e) : '') : null, f ? (f ? K(x, f) : '') : null, h ? (h ? K(x, h) : '') : null, k ? (k ? K(x, k) : '') : null); + } + return 0; + }; + })(); + (() => { + const a = new Map(), + b = new Map(); + d.createFunction = (c, e, f, h, k, n) => { + const l = a.size; + a.set(l, { f: n, If: k }); + return Z('create_function', 'number', 'number string number number number number'.split(' '), [c, e, f, h, l, 0]); + }; + d.createAggregate = (c, e, f, h, k, n, l) => { + const m = a.size; + a.set(m, { step: n, sg: l, If: k }); + return Z('create_function', 'number', 'number string number number number number'.split(' '), [c, e, f, h, m, 1]); + }; + d.getFunctionUserData = (c) => b.get(c); + bc = (c, e, f, h) => { + c = a.get(c); + b.set(e, c.If); + c.f(e, new Uint32Array(x.buffer, h, f)); + b.delete(e); + }; + dc = (c, e, f, h) => { + c = a.get(c); + b.set(e, c.If); + c.step(e, new Uint32Array(x.buffer, h, f)); + b.delete(e); + }; + ac = (c, e) => { + c = a.get(c); + b.set(e, c.If); + c.sg(e); + b.delete(e); + }; + })(); + (() => { + const a = new Map(); + d.progressHandler = (b, c, e, f) => { + e ? a.set(b, { f: e, dg: f }) : a.delete(b); + return Z('progress_handler', null, ['number', 'number'], [b, c]); + }; + cc = (b) => { + if (a.has(b)) { + const { f: c, dg: e } = a.get(b); + return c(e); + } + return 0; + }; + })(); + (() => { + function a(l, m) { + const q = `get${l}`, + p = `set${l}`; + return new Proxy(new DataView(x.buffer, m, 'Int32' === l ? 4 : 8), { + get(t, y) { + if (y === q) + return (B, G) => { + if (!G) throw Error('must be little endian'); + return t[y](B, G); + }; + if (y === p) + return (B, G, E) => { + if (!E) throw Error('must be little endian'); + return t[y](B, G, E); + }; + if ('string' === typeof y && y.match(/^(get)|(set)/)) throw Error('invalid type'); + return t[y]; + }, + }); + } + const b = 'object' === typeof gd, + c = new Map(), + e = new Map(), + f = new Map(), + h = b ? new Set() : null, + k = b ? new Set() : null, + n = new Map(); + uc = (l, m, q, p) => { + n.set(l ? K(x, l) : '', { size: m, Pf: Array.from(new Uint32Array(x.buffer, p, q)) }); + }; + d.createModule = (l, m, q, p) => { + b && (q.handleAsync = fd); + const t = c.size; + c.set(t, { module: q, If: p }); + p = 0; + q.xCreate && (p |= 1); + q.xConnect && (p |= 2); + q.xBestIndex && (p |= 4); + q.xDisconnect && (p |= 8); + q.xDestroy && (p |= 16); + q.xOpen && (p |= 32); + q.xClose && (p |= 64); + q.xFilter && (p |= 128); + q.xNext && (p |= 256); + q.xEof && (p |= 512); + q.xColumn && (p |= 1024); + q.xRowid && (p |= 2048); + q.xUpdate && (p |= 4096); + q.xBegin && (p |= 8192); + q.xSync && (p |= 16384); + q.xCommit && (p |= 32768); + q.xRollback && (p |= 65536); + q.xFindFunction && (p |= 131072); + q.xRename && (p |= 262144); + return Z('create_module', 'number', ['number', 'string', 'number', 'number'], [l, m, t, p]); + }; + kc = (l, m, q, p, t, y) => { + m = c.get(m); + e.set(t, m); + if (b) { + h.delete(t); + for (const B of h) e.delete(B); + } + p = Array.from(new Uint32Array(x.buffer, p, q)).map((B) => (B ? K(x, B) : '')); + return m.module.xCreate(l, m.If, p, t, a('Int32', y)); + }; + jc = (l, m, q, p, t, y) => { + m = c.get(m); + e.set(t, m); + if (b) { + h.delete(t); + for (const B of h) e.delete(B); + } + p = Array.from(new Uint32Array(x.buffer, p, q)).map((B) => (B ? K(x, B) : '')); + return m.module.xConnect(l, m.If, p, t, a('Int32', y)); + }; + fc = (l, m) => { + var q = e.get(l), + p = n.get('sqlite3_index_info').Pf; + const t = {}; + t.nConstraint = H(m + p[0], 'i32'); + t.aConstraint = []; + var y = H(m + p[1], '*'), + B = n.get('sqlite3_index_constraint').size; + for (var G = 0; G < t.nConstraint; ++G) { + var E = t.aConstraint, + L = E.push, + I = y + G * B, + ka = n.get('sqlite3_index_constraint').Pf, + X = {}; + X.iColumn = H(I + ka[0], 'i32'); + X.op = H(I + ka[1], 'i8'); + X.usable = !!H(I + ka[2], 'i8'); + L.call(E, X); + } + t.nOrderBy = H(m + p[2], 'i32'); + t.aOrderBy = []; + y = H(m + p[3], '*'); + B = n.get('sqlite3_index_orderby').size; + for (G = 0; G < t.nOrderBy; ++G) + (E = t.aOrderBy), + (L = E.push), + (I = y + G * B), + (ka = n.get('sqlite3_index_orderby').Pf), + (X = {}), + (X.iColumn = H(I + ka[0], 'i32')), + (X.desc = !!H(I + ka[1], 'i8')), + L.call(E, X); + t.aConstraintUsage = []; + for (y = 0; y < t.nConstraint; ++y) t.aConstraintUsage.push({ argvIndex: 0, omit: !1 }); + t.idxNum = H(m + p[5], 'i32'); + t.idxStr = null; + t.orderByConsumed = !!H(m + p[8], 'i8'); + t.estimatedCost = H(m + p[9], 'double'); + t.estimatedRows = H(m + p[10], 'i32'); + t.idxFlags = H(m + p[11], 'i32'); + t.colUsed = H(m + p[12], 'i32'); + l = q.module.xBestIndex(l, t); + q = n.get('sqlite3_index_info').Pf; + p = H(m + q[4], '*'); + y = n.get('sqlite3_index_constraint_usage').size; + for (L = 0; L < t.nConstraint; ++L) + (B = p + L * y), + (E = t.aConstraintUsage[L]), + (I = n.get('sqlite3_index_constraint_usage').Pf), + J(B + I[0], E.argvIndex, 'i32'), + J(B + I[1], E.omit ? 1 : 0, 'i8'); + J(m + q[5], t.idxNum, 'i32'); + 'string' === typeof t.idxStr && + ((p = Sa(t.idxStr)), + (y = Z('sqlite3_malloc', 'number', ['number'], [p + 1])), + Ta(t.idxStr, x, y, p + 1), + J(m + q[6], y, '*'), + J(m + q[7], 1, 'i32')); + J(m + q[8], t.orderByConsumed, 'i32'); + J(m + q[9], t.estimatedCost, 'double'); + J(m + q[10], t.estimatedRows, 'i32'); + J(m + q[11], t.idxFlags, 'i32'); + return l; + }; + mc = (l) => { + const m = e.get(l); + b ? h.add(l) : e.delete(l); + return m.module.xDisconnect(l); + }; + lc = (l) => { + const m = e.get(l); + b ? h.add(l) : e.delete(l); + return m.module.xDestroy(l); + }; + qc = (l, m) => { + const q = e.get(l); + f.set(m, q); + if (b) { + k.delete(m); + for (const p of k) f.delete(p); + } + return q.module.xOpen(l, m); + }; + gc = (l) => { + const m = f.get(l); + b ? k.add(l) : f.delete(l); + return m.module.xClose(l); + }; + nc = (l) => (f.get(l).module.xEof(l) ? 1 : 0); + oc = (l, m, q, p, t) => { + const y = f.get(l); + q = q ? (q ? K(x, q) : '') : null; + t = new Uint32Array(x.buffer, t, p); + return y.module.xFilter(l, m, q, t); + }; + pc = (l) => f.get(l).module.xNext(l); + hc = (l, m, q) => f.get(l).module.xColumn(l, m, q); + tc = (l, m) => f.get(l).module.xRowid(l, a('BigInt64', m)); + wc = (l, m, q, p) => { + const t = e.get(l); + q = new Uint32Array(x.buffer, q, m); + return t.module.xUpdate(l, q, a('BigInt64', p)); + }; + ec = (l) => e.get(l).module.xBegin(l); + vc = (l) => e.get(l).module.xSync(l); + ic = (l) => e.get(l).module.xCommit(l); + sc = (l) => e.get(l).module.xRollback(l); + rc = (l, m) => { + const q = e.get(l); + m = m ? K(x, m) : ''; + return q.module.xRename(l, m); + }; + })(); + (() => { + function a(h, k) { + const n = `get${h}`, + l = `set${h}`; + return new Proxy(new DataView(x.buffer, k, 'Int32' === h ? 4 : 8), { + get(m, q) { + if (q === n) + return (p, t) => { + if (!t) throw Error('must be little endian'); + return m[q](p, t); + }; + if (q === l) + return (p, t, y) => { + if (!y) throw Error('must be little endian'); + return m[q](p, t, y); + }; + if ('string' === typeof q && q.match(/^(get)|(set)/)) throw Error('invalid type'); + return m[q]; + }, + }); + } + const b = 'object' === typeof gd; + b && (d.handleAsync = fd); + const c = new Map(), + e = new Map(); + d.registerVFS = (h, k) => { + if (Z('sqlite3_vfs_find', 'number', ['string'], [h.name])) throw Error(`VFS '${h.name}' already registered`); + b && (h.handleAsync = fd); + var n = h.Lg ?? 64; + const l = d._malloc(4); + k = Z('register_vfs', 'number', ['string', 'number', 'number', 'number'], [h.name, n, k ? 1 : 0, l]); + k || ((n = H(l, '*')), c.set(n, h)); + d._free(l); + return k; + }; + const f = b ? new Set() : null; + zc = (h) => { + const k = e.get(h); + b ? f.add(h) : e.delete(h); + return k.xClose(h); + }; + Gc = (h, k, n, l, m) => e.get(h).xRead(h, x.subarray(k, k + n), 4294967296 * m + l + (0 > l ? 2 ** 32 : 0)); + Lc = (h, k, n, l, m) => e.get(h).xWrite(h, x.subarray(k, k + n), 4294967296 * m + l + (0 > l ? 2 ** 32 : 0)); + Jc = (h, k, n) => e.get(h).xTruncate(h, 4294967296 * n + k + (0 > k ? 2 ** 32 : 0)); + Ic = (h, k) => e.get(h).xSync(h, k); + Dc = (h, k) => { + const n = e.get(h); + k = a('BigInt64', k); + return n.xFileSize(h, k); + }; + Ec = (h, k) => e.get(h).xLock(h, k); + Kc = (h, k) => e.get(h).xUnlock(h, k); + yc = (h, k) => { + const n = e.get(h); + k = a('Int32', k); + return n.xCheckReservedLock(h, k); + }; + Cc = (h, k, n) => { + const l = e.get(h); + n = new DataView(x.buffer, n); + return l.xFileControl(h, k, n); + }; + Hc = (h) => e.get(h).xSectorSize(h); + Bc = (h) => e.get(h).xDeviceCharacteristics(h); + Fc = (h, k, n, l, m) => { + h = c.get(h); + e.set(n, h); + if (b) { + f.delete(n); + for (var q of f) e.delete(q); + } + q = null; + if (l & 64) { + q = 1; + const p = []; + while (q) { + const t = x[k++]; + if (t) p.push(t); + else + switch ((x[k] || (q = null), q)) { + case 1: + p.push(63); + q = 2; + break; + case 2: + p.push(61); + q = 3; + break; + case 3: + p.push(38), (q = 2); + } + } + q = new TextDecoder().decode(new Uint8Array(p)); + } else k && (q = k ? K(x, k) : ''); + m = a('Int32', m); + return h.xOpen(q, n, l, m); + }; + Ac = (h, k, n) => c.get(h).xDelete(k ? K(x, k) : '', n); + xc = (h, k, n, l) => { + h = c.get(h); + l = a('Int32', l); + return h.xAccess(k ? K(x, k) : '', n, l); + }; + })(); + var qd = { + a: (a, b, c, e) => { + u( + `Assertion failed: ${a ? K(x, a) : ''}, at: ` + + [b ? (b ? K(x, b) : '') : 'unknown filename', c, e ? (e ? K(x, e) : '') : 'unknown function'], + ); + }, + N: (a, b) => { + try { + return (a = a ? K(x, a) : ''), Fb(a, b), 0; + } catch (c) { + if ('undefined' == typeof U || 'ErrnoError' !== c.name) throw c; + return -c.zf; + } + }, + Q: (a, b, c) => { + try { + b = b ? K(x, b) : ''; + b = Ob(a, b); + if (c & -8) return -28; + var e = R(b, { Jf: !0 }).node; + if (!e) return -44; + a = ''; + c & 4 && (a += 'r'); + c & 2 && (a += 'w'); + c & 1 && (a += 'x'); + return a && qb(e, a) ? -2 : 0; + } catch (f) { + if ('undefined' == typeof U || 'ErrnoError' !== f.name) throw f; + return -f.zf; + } + }, + O: (a, b) => { + try { + var c = S(a); + Fb(c.node, b); + return 0; + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return -e.zf; + } + }, + M: (a) => { + try { + var b = S(a).node; + var c = 'string' == typeof b ? R(b, { Jf: !0 }).node : b; + if (!c.wf.Bf) throw new N(63); + c.wf.Bf(c, { timestamp: Date.now() }); + return 0; + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return -e.zf; + } + }, + b: (a, b, c) => { + Qb = c; + try { + var e = S(a); + switch (b) { + case 0: + var f = Rb(); + if (0 > f) return -28; + while (jb[f]) f++; + return wb(e, f).Hf; + case 1: + case 2: + return 0; + case 3: + return e.flags; + case 4: + return (f = Rb()), (e.flags |= f), 0; + case 5: + return (f = Rb()), (z[(f + 0) >> 1] = 2), 0; + case 6: + case 7: + return 0; + case 16: + case 8: + return -28; + case 9: + return (A[pd() >> 2] = 28), -1; + default: + return -28; + } + } catch (h) { + if ('undefined' == typeof U || 'ErrnoError' !== h.name) throw h; + return -h.zf; + } + }, + L: (a, b) => { + try { + var c = S(a); + return Pb(Db, c.path, b); + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return -e.zf; + } + }, + n: (a, b, c) => { + b = Sb(b, c); + try { + if (isNaN(b)) return 61; + var e = S(a); + if (0 === (e.flags & 2097155)) throw new N(28); + Gb(e.node, b); + return 0; + } catch (f) { + if ('undefined' == typeof U || 'ErrnoError' !== f.name) throw f; + return -f.zf; + } + }, + F: (a, b) => { + try { + if (0 === b) return -28; + var c = Sa('/') + 1; + if (b < c) return -68; + Ta('/', x, a, b); + return c; + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return -e.zf; + } + }, + J: (a, b) => { + try { + return (a = a ? K(x, a) : ''), Pb(Eb, a, b); + } catch (c) { + if ('undefined' == typeof U || 'ErrnoError' !== c.name) throw c; + return -c.zf; + } + }, + C: (a, b, c) => { + try { + return (b = b ? K(x, b) : ''), (b = Ob(a, b)), (b = M(b)), '/' === b[b.length - 1] && (b = b.substr(0, b.length - 1)), T(b, c), 0; + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return -e.zf; + } + }, + I: (a, b, c, e) => { + try { + b = b ? K(x, b) : ''; + var f = e & 256; + b = Ob(a, b, e & 4096); + return Pb(f ? Eb : Db, b, c); + } catch (h) { + if ('undefined' == typeof U || 'ErrnoError' !== h.name) throw h; + return -h.zf; + } + }, + B: (a, b, c, e) => { + Qb = e; + try { + b = b ? K(x, b) : ''; + b = Ob(a, b); + var f = e ? Rb() : 0; + return Hb(b, c, f).Hf; + } catch (h) { + if ('undefined' == typeof U || 'ErrnoError' !== h.name) throw h; + return -h.zf; + } + }, + z: (a, b, c, e) => { + try { + b = b ? K(x, b) : ''; + b = Ob(a, b); + if (0 >= e) return -28; + var f = mb(b), + h = Math.min(e, Sa(f)), + k = w[c + h]; + Ta(f, x, c, e + 1); + w[c + h] = k; + return h; + } catch (n) { + if ('undefined' == typeof U || 'ErrnoError' !== n.name) throw n; + return -n.zf; + } + }, + y: (a) => { + try { + return (a = a ? K(x, a) : ''), Cb(a), 0; + } catch (b) { + if ('undefined' == typeof U || 'ErrnoError' !== b.name) throw b; + return -b.zf; + } + }, + K: (a, b) => { + try { + return (a = a ? K(x, a) : ''), Pb(Db, a, b); + } catch (c) { + if ('undefined' == typeof U || 'ErrnoError' !== c.name) throw c; + return -c.zf; + } + }, + u: (a, b, c) => { + try { + b = b ? K(x, b) : ''; + b = Ob(a, b); + if (0 === c) { + a = b; + var e = R(a, { parent: !0 }).node; + if (!e) throw new N(44); + var f = Na(a), + h = eb(e, f), + k = ub(e, f, !1); + if (k) throw new N(k); + if (!e.wf.Yf) throw new N(63); + if (h.Lf) throw new N(10); + e.wf.Yf(e, f); + pb(h); + } else 512 === c ? Cb(b) : u('Invalid flags passed to unlinkat'); + return 0; + } catch (n) { + if ('undefined' == typeof U || 'ErrnoError' !== n.name) throw n; + return -n.zf; + } + }, + t: (a, b, c) => { + try { + b = b ? K(x, b) : ''; + b = Ob(a, b, !0); + if (c) { + var e = C[c >> 2] + 4294967296 * A[(c + 4) >> 2], + f = A[(c + 8) >> 2]; + h = 1e3 * e + f / 1e6; + c += 16; + e = C[c >> 2] + 4294967296 * A[(c + 4) >> 2]; + f = A[(c + 8) >> 2]; + k = 1e3 * e + f / 1e6; + } else + var h = Date.now(), + k = h; + a = h; + var n = R(b, { Jf: !0 }).node; + n.wf.Bf(n, { timestamp: Math.max(a, k) }); + return 0; + } catch (l) { + if ('undefined' == typeof U || 'ErrnoError' !== l.name) throw l; + return -l.zf; + } + }, + l: (a, b, c) => { + a = new Date(1e3 * Sb(a, b)); + A[c >> 2] = a.getSeconds(); + A[(c + 4) >> 2] = a.getMinutes(); + A[(c + 8) >> 2] = a.getHours(); + A[(c + 12) >> 2] = a.getDate(); + A[(c + 16) >> 2] = a.getMonth(); + A[(c + 20) >> 2] = a.getFullYear() - 1900; + A[(c + 24) >> 2] = a.getDay(); + b = a.getFullYear(); + A[(c + 28) >> 2] = ((0 !== b % 4 || (0 === b % 100 && 0 !== b % 400) ? Ub : Tb)[a.getMonth()] + a.getDate() - 1) | 0; + A[(c + 36) >> 2] = -(60 * a.getTimezoneOffset()); + b = new Date(a.getFullYear(), 6, 1).getTimezoneOffset(); + var e = new Date(a.getFullYear(), 0, 1).getTimezoneOffset(); + A[(c + 32) >> 2] = (b != e && a.getTimezoneOffset() == Math.min(e, b)) | 0; + }, + i: (a, b, c, e, f, h, k, n) => { + f = Sb(f, h); + try { + if (isNaN(f)) return 61; + var l = S(e); + if (0 !== (b & 2) && 0 === (c & 2) && 2 !== (l.flags & 2097155)) throw new N(2); + if (1 === (l.flags & 2097155)) throw new N(2); + if (!l.xf.Uf) throw new N(43); + var m = l.xf.Uf(l, a, f, b, c); + var q = m.yg; + A[k >> 2] = m.mg; + C[n >> 2] = q; + return 0; + } catch (p) { + if ('undefined' == typeof U || 'ErrnoError' !== p.name) throw p; + return -p.zf; + } + }, + j: (a, b, c, e, f, h, k) => { + h = Sb(h, k); + try { + if (isNaN(h)) return 61; + var n = S(f); + if (c & 2) { + if (32768 !== (n.node.mode & 61440)) throw new N(43); + e & 2 || (n.xf.Vf && n.xf.Vf(n, x.slice(a, a + b), h, b, e)); + } + } catch (l) { + if ('undefined' == typeof U || 'ErrnoError' !== l.name) throw l; + return -l.zf; + } + }, + w: (a, b, c) => { + function e(l) { + return (l = l.toTimeString().match(/\(([A-Za-z ]+)\)$/)) ? l[1] : 'GMT'; + } + var f = new Date().getFullYear(), + h = new Date(f, 0, 1), + k = new Date(f, 6, 1); + f = h.getTimezoneOffset(); + var n = k.getTimezoneOffset(); + C[a >> 2] = 60 * Math.max(f, n); + A[b >> 2] = Number(f != n); + a = e(h); + b = e(k); + a = Wb(a); + b = Wb(b); + n < f ? ((C[c >> 2] = a), (C[(c + 4) >> 2] = b)) : ((C[c >> 2] = b), (C[(c + 4) >> 2] = a)); + }, + e: () => Date.now(), + d: () => performance.now(), + r: (a) => { + var b = x.length; + a >>>= 0; + if (2147483648 < a) return !1; + for (var c = 1; 4 >= c; c *= 2) { + var e = b * (1 + 0.2 / c); + e = Math.min(e, a + 100663296); + var f = Math; + e = Math.max(a, e); + a: { + f = (f.min.call(f, 2147483648, e + ((65536 - (e % 65536)) % 65536)) - ma.buffer.byteLength + 65535) / 65536; + try { + ma.grow(f); + ra(); + var h = 1; + break a; + } catch (k) {} + h = void 0; + } + if (h) return !0; + } + return !1; + }, + D: (a, b) => { + var c = 0; + Zb().forEach((e, f) => { + var h = b + c; + f = C[(a + 4 * f) >> 2] = h; + for (h = 0; h < e.length; ++h) w[f++ >> 0] = e.charCodeAt(h); + w[f >> 0] = 0; + c += e.length + 1; + }); + return 0; + }, + E: (a, b) => { + var c = Zb(); + C[a >> 2] = c.length; + var e = 0; + c.forEach((f) => (e += f.length + 1)); + C[b >> 2] = e; + return 0; + }, + f: (a) => { + try { + var b = S(a); + if (null === b.Hf) throw new N(8); + b.$f && (b.$f = null); + try { + b.xf.close && b.xf.close(b); + } catch (c) { + throw c; + } finally { + jb[b.Hf] = null; + } + b.Hf = null; + return 0; + } catch (c) { + if ('undefined' == typeof U || 'ErrnoError' !== c.name) throw c; + return c.zf; + } + }, + s: (a, b) => { + try { + var c = S(a); + w[b >> 0] = c.Df ? 2 : P(c.mode) ? 3 : 40960 === (c.mode & 61440) ? 7 : 4; + z[(b + 2) >> 1] = 0; + F = [ + 0, + ((D = 0), 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(b + 8) >> 2] = F[0]; + A[(b + 12) >> 2] = F[1]; + F = [ + 0, + ((D = 0), 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[(b + 16) >> 2] = F[0]; + A[(b + 20) >> 2] = F[1]; + return 0; + } catch (e) { + if ('undefined' == typeof U || 'ErrnoError' !== e.name) throw e; + return e.zf; + } + }, + A: (a, b, c, e) => { + try { + a: { + var f = S(a); + a = b; + for (var h, k = (b = 0); k < c; k++) { + var n = C[a >> 2], + l = C[(a + 4) >> 2]; + a += 8; + var m = f, + q = n, + p = l, + t = h, + y = w; + if (0 > p || 0 > t) throw new N(28); + if (null === m.Hf) throw new N(8); + if (1 === (m.flags & 2097155)) throw new N(8); + if (P(m.node.mode)) throw new N(31); + if (!m.xf.read) throw new N(28); + var B = 'undefined' != typeof t; + if (!B) t = m.position; + else if (!m.seekable) throw new N(70); + var G = m.xf.read(m, y, q, p, t); + B || (m.position += G); + var E = G; + if (0 > E) { + var L = -1; + break a; + } + b += E; + if (E < l) break; + 'undefined' !== typeof h && (h += E); + } + L = b; + } + C[e >> 2] = L; + return 0; + } catch (I) { + if ('undefined' == typeof U || 'ErrnoError' !== I.name) throw I; + return I.zf; + } + }, + m: (a, b, c, e, f) => { + b = Sb(b, c); + try { + if (isNaN(b)) return 61; + var h = S(a); + Jb(h, b, e); + F = [ + h.position >>> 0, + ((D = h.position), + 1 <= +Math.abs(D) ? (0 < D ? +Math.floor(D / 4294967296) >>> 0 : ~~+Math.ceil((D - +(~~D >>> 0)) / 4294967296) >>> 0) : 0), + ]; + A[f >> 2] = F[0]; + A[(f + 4) >> 2] = F[1]; + h.$f && 0 === b && 0 === e && (h.$f = null); + return 0; + } catch (k) { + if ('undefined' == typeof U || 'ErrnoError' !== k.name) throw k; + return k.zf; + } + }, + H: (a) => { + try { + var b = S(a); + return ad((c) => { + var e = b.node.Ff; + e.type.Ag + ? e.type.Ag(e, !1, (f) => { + f ? c(29) : c(0); + }) + : c(0); + }); + } catch (c) { + if ('undefined' == typeof U || 'ErrnoError' !== c.name) throw c; + return c.zf; + } + }, + x: (a, b, c, e) => { + try { + a: { + var f = S(a); + a = b; + for (var h, k = (b = 0); k < c; k++) { + var n = C[a >> 2], + l = C[(a + 4) >> 2]; + a += 8; + var m = f, + q = n, + p = l, + t = h, + y = w; + if (0 > p || 0 > t) throw new N(28); + if (null === m.Hf) throw new N(8); + if (0 === (m.flags & 2097155)) throw new N(8); + if (P(m.node.mode)) throw new N(31); + if (!m.xf.write) throw new N(28); + m.seekable && m.flags & 1024 && Jb(m, 0, 2); + var B = 'undefined' != typeof t; + if (!B) t = m.position; + else if (!m.seekable) throw new N(70); + var G = m.xf.write(m, y, q, p, t, void 0); + B || (m.position += G); + var E = G; + if (0 > E) { + var L = -1; + break a; + } + b += E; + 'undefined' !== typeof h && (h += E); + } + L = b; + } + C[e >> 2] = L; + return 0; + } catch (I) { + if ('undefined' == typeof U || 'ErrnoError' !== I.name) throw I; + return I.zf; + } + }, + ra: $b, + P: ac, + ga: bc, + ca: cc, + Y: dc, + la: ec, + G: fc, + h: gc, + oa: hc, + ja: ic, + ea: jc, + fa: kc, + k: lc, + v: mc, + pa: nc, + g: oc, + qa: pc, + da: qc, + ha: rc, + ia: sc, + na: tc, + c: uc, + ka: vc, + ma: wc, + aa: xc, + V: yc, + $: zc, + ba: Ac, + S: Bc, + U: Cc, + Z: Dc, + X: Ec, + R: Fc, + q: Gc, + T: Hc, + _: Ic, + o: Jc, + W: Kc, + p: Lc, + }, + V = (() => { + function a(c) { + V = c.exports; + V = Qc(); + ma = V.sa; + ra(); + hd = V.mf; + ta.unshift(V.ta); + xa--; + d.monitorRunDependencies && d.monitorRunDependencies(xa); + 0 == xa && (null !== ya && (clearInterval(ya), (ya = null)), za && ((c = za), (za = null), c())); + return V; + } + var b = { a: qd }; + xa++; + d.monitorRunDependencies && d.monitorRunDependencies(xa); + if (d.instantiateWasm) + try { + return d.instantiateWasm(b, a); + } catch (c) { + r(`Module.instantiateWasm callback failed with error: ${c}`), ba(c); + } + Ga(b, (c) => { + a(c.instance); + }).catch(ba); + return {}; + })(); + d._sqlite3_status64 = (a, b, c, e) => (d._sqlite3_status64 = V.ua)(a, b, c, e); + d._sqlite3_status = (a, b, c, e) => (d._sqlite3_status = V.va)(a, b, c, e); + d._sqlite3_db_status = (a, b, c, e, f) => (d._sqlite3_db_status = V.wa)(a, b, c, e, f); + d._sqlite3_msize = (a) => (d._sqlite3_msize = V.xa)(a); + d._sqlite3_vfs_find = (a) => (d._sqlite3_vfs_find = V.ya)(a); + d._sqlite3_vfs_register = (a, b) => (d._sqlite3_vfs_register = V.za)(a, b); + d._sqlite3_vfs_unregister = (a) => (d._sqlite3_vfs_unregister = V.Aa)(a); + d._sqlite3_release_memory = (a) => (d._sqlite3_release_memory = V.Ba)(a); + d._sqlite3_soft_heap_limit64 = (a, b) => (d._sqlite3_soft_heap_limit64 = V.Ca)(a, b); + d._sqlite3_memory_used = () => (d._sqlite3_memory_used = V.Da)(); + d._sqlite3_hard_heap_limit64 = (a, b) => (d._sqlite3_hard_heap_limit64 = V.Ea)(a, b); + d._sqlite3_memory_highwater = (a) => (d._sqlite3_memory_highwater = V.Fa)(a); + d._sqlite3_malloc = (a) => (d._sqlite3_malloc = V.Ga)(a); + d._sqlite3_malloc64 = (a, b) => (d._sqlite3_malloc64 = V.Ha)(a, b); + d._sqlite3_free = (a) => (d._sqlite3_free = V.Ia)(a); + d._sqlite3_realloc = (a, b) => (d._sqlite3_realloc = V.Ja)(a, b); + d._sqlite3_realloc64 = (a, b, c) => (d._sqlite3_realloc64 = V.Ka)(a, b, c); + d._sqlite3_str_vappendf = (a, b, c) => (d._sqlite3_str_vappendf = V.La)(a, b, c); + d._sqlite3_str_append = (a, b, c) => (d._sqlite3_str_append = V.Ma)(a, b, c); + d._sqlite3_str_appendchar = (a, b, c) => (d._sqlite3_str_appendchar = V.Na)(a, b, c); + d._sqlite3_str_appendall = (a, b) => (d._sqlite3_str_appendall = V.Oa)(a, b); + d._sqlite3_str_appendf = (a, b, c) => (d._sqlite3_str_appendf = V.Pa)(a, b, c); + d._sqlite3_str_finish = (a) => (d._sqlite3_str_finish = V.Qa)(a); + d._sqlite3_str_errcode = (a) => (d._sqlite3_str_errcode = V.Ra)(a); + d._sqlite3_str_length = (a) => (d._sqlite3_str_length = V.Sa)(a); + d._sqlite3_str_value = (a) => (d._sqlite3_str_value = V.Ta)(a); + d._sqlite3_str_reset = (a) => (d._sqlite3_str_reset = V.Ua)(a); + d._sqlite3_str_new = (a) => (d._sqlite3_str_new = V.Va)(a); + d._sqlite3_vmprintf = (a, b) => (d._sqlite3_vmprintf = V.Wa)(a, b); + d._sqlite3_mprintf = (a, b) => (d._sqlite3_mprintf = V.Xa)(a, b); + d._sqlite3_vsnprintf = (a, b, c, e) => (d._sqlite3_vsnprintf = V.Ya)(a, b, c, e); + d._sqlite3_snprintf = (a, b, c, e) => (d._sqlite3_snprintf = V.Za)(a, b, c, e); + d._sqlite3_log = (a, b, c) => (d._sqlite3_log = V._a)(a, b, c); + d._sqlite3_randomness = (a, b) => (d._sqlite3_randomness = V.$a)(a, b); + d._sqlite3_stricmp = (a, b) => (d._sqlite3_stricmp = V.ab)(a, b); + d._sqlite3_strnicmp = (a, b, c) => (d._sqlite3_strnicmp = V.bb)(a, b, c); + d._sqlite3_os_init = () => (d._sqlite3_os_init = V.cb)(); + d._sqlite3_os_end = () => (d._sqlite3_os_end = V.db)(); + d._sqlite3_serialize = (a, b, c, e) => (d._sqlite3_serialize = V.eb)(a, b, c, e); + d._sqlite3_prepare_v2 = (a, b, c, e, f) => (d._sqlite3_prepare_v2 = V.fb)(a, b, c, e, f); + d._sqlite3_step = (a) => (d._sqlite3_step = V.gb)(a); + d._sqlite3_column_int64 = (a, b) => (d._sqlite3_column_int64 = V.hb)(a, b); + d._sqlite3_column_int = (a, b) => (d._sqlite3_column_int = V.ib)(a, b); + d._sqlite3_finalize = (a) => (d._sqlite3_finalize = V.jb)(a); + d._sqlite3_deserialize = (a, b, c, e, f, h, k, n) => (d._sqlite3_deserialize = V.kb)(a, b, c, e, f, h, k, n); + d._sqlite3_database_file_object = (a) => (d._sqlite3_database_file_object = V.lb)(a); + d._sqlite3_backup_init = (a, b, c, e) => (d._sqlite3_backup_init = V.mb)(a, b, c, e); + d._sqlite3_backup_step = (a, b) => (d._sqlite3_backup_step = V.nb)(a, b); + d._sqlite3_backup_finish = (a) => (d._sqlite3_backup_finish = V.ob)(a); + d._sqlite3_backup_remaining = (a) => (d._sqlite3_backup_remaining = V.pb)(a); + d._sqlite3_backup_pagecount = (a) => (d._sqlite3_backup_pagecount = V.qb)(a); + d._sqlite3_reset = (a) => (d._sqlite3_reset = V.rb)(a); + d._sqlite3_clear_bindings = (a) => (d._sqlite3_clear_bindings = V.sb)(a); + d._sqlite3_value_blob = (a) => (d._sqlite3_value_blob = V.tb)(a); + d._sqlite3_value_text = (a) => (d._sqlite3_value_text = V.ub)(a); + d._sqlite3_value_bytes = (a) => (d._sqlite3_value_bytes = V.vb)(a); + d._sqlite3_value_bytes16 = (a) => (d._sqlite3_value_bytes16 = V.wb)(a); + d._sqlite3_value_double = (a) => (d._sqlite3_value_double = V.xb)(a); + d._sqlite3_value_int = (a) => (d._sqlite3_value_int = V.yb)(a); + d._sqlite3_value_int64 = (a) => (d._sqlite3_value_int64 = V.zb)(a); + d._sqlite3_value_subtype = (a) => (d._sqlite3_value_subtype = V.Ab)(a); + d._sqlite3_value_pointer = (a, b) => (d._sqlite3_value_pointer = V.Bb)(a, b); + d._sqlite3_value_text16 = (a) => (d._sqlite3_value_text16 = V.Cb)(a); + d._sqlite3_value_text16be = (a) => (d._sqlite3_value_text16be = V.Db)(a); + d._sqlite3_value_text16le = (a) => (d._sqlite3_value_text16le = V.Eb)(a); + d._sqlite3_value_type = (a) => (d._sqlite3_value_type = V.Fb)(a); + d._sqlite3_value_encoding = (a) => (d._sqlite3_value_encoding = V.Gb)(a); + d._sqlite3_value_nochange = (a) => (d._sqlite3_value_nochange = V.Hb)(a); + d._sqlite3_value_frombind = (a) => (d._sqlite3_value_frombind = V.Ib)(a); + d._sqlite3_value_dup = (a) => (d._sqlite3_value_dup = V.Jb)(a); + d._sqlite3_value_free = (a) => (d._sqlite3_value_free = V.Kb)(a); + d._sqlite3_result_blob = (a, b, c, e) => (d._sqlite3_result_blob = V.Lb)(a, b, c, e); + d._sqlite3_result_blob64 = (a, b, c, e, f) => (d._sqlite3_result_blob64 = V.Mb)(a, b, c, e, f); + d._sqlite3_result_double = (a, b) => (d._sqlite3_result_double = V.Nb)(a, b); + d._sqlite3_result_error = (a, b, c) => (d._sqlite3_result_error = V.Ob)(a, b, c); + d._sqlite3_result_error16 = (a, b, c) => (d._sqlite3_result_error16 = V.Pb)(a, b, c); + d._sqlite3_result_int = (a, b) => (d._sqlite3_result_int = V.Qb)(a, b); + d._sqlite3_result_int64 = (a, b, c) => (d._sqlite3_result_int64 = V.Rb)(a, b, c); + d._sqlite3_result_null = (a) => (d._sqlite3_result_null = V.Sb)(a); + d._sqlite3_result_pointer = (a, b, c, e) => (d._sqlite3_result_pointer = V.Tb)(a, b, c, e); + d._sqlite3_result_subtype = (a, b) => (d._sqlite3_result_subtype = V.Ub)(a, b); + d._sqlite3_result_text = (a, b, c, e) => (d._sqlite3_result_text = V.Vb)(a, b, c, e); + d._sqlite3_result_text64 = (a, b, c, e, f, h) => (d._sqlite3_result_text64 = V.Wb)(a, b, c, e, f, h); + d._sqlite3_result_text16 = (a, b, c, e) => (d._sqlite3_result_text16 = V.Xb)(a, b, c, e); + d._sqlite3_result_text16be = (a, b, c, e) => (d._sqlite3_result_text16be = V.Yb)(a, b, c, e); + d._sqlite3_result_text16le = (a, b, c, e) => (d._sqlite3_result_text16le = V.Zb)(a, b, c, e); + d._sqlite3_result_value = (a, b) => (d._sqlite3_result_value = V._b)(a, b); + d._sqlite3_result_error_toobig = (a) => (d._sqlite3_result_error_toobig = V.$b)(a); + d._sqlite3_result_zeroblob = (a, b) => (d._sqlite3_result_zeroblob = V.ac)(a, b); + d._sqlite3_result_zeroblob64 = (a, b, c) => (d._sqlite3_result_zeroblob64 = V.bc)(a, b, c); + d._sqlite3_result_error_code = (a, b) => (d._sqlite3_result_error_code = V.cc)(a, b); + d._sqlite3_result_error_nomem = (a) => (d._sqlite3_result_error_nomem = V.dc)(a); + d._sqlite3_user_data = (a) => (d._sqlite3_user_data = V.ec)(a); + d._sqlite3_context_db_handle = (a) => (d._sqlite3_context_db_handle = V.fc)(a); + d._sqlite3_vtab_nochange = (a) => (d._sqlite3_vtab_nochange = V.gc)(a); + d._sqlite3_vtab_in_first = (a, b) => (d._sqlite3_vtab_in_first = V.hc)(a, b); + d._sqlite3_vtab_in_next = (a, b) => (d._sqlite3_vtab_in_next = V.ic)(a, b); + d._sqlite3_aggregate_context = (a, b) => (d._sqlite3_aggregate_context = V.jc)(a, b); + d._sqlite3_get_auxdata = (a, b) => (d._sqlite3_get_auxdata = V.kc)(a, b); + d._sqlite3_set_auxdata = (a, b, c, e) => (d._sqlite3_set_auxdata = V.lc)(a, b, c, e); + d._sqlite3_column_count = (a) => (d._sqlite3_column_count = V.mc)(a); + d._sqlite3_data_count = (a) => (d._sqlite3_data_count = V.nc)(a); + d._sqlite3_column_blob = (a, b) => (d._sqlite3_column_blob = V.oc)(a, b); + d._sqlite3_column_bytes = (a, b) => (d._sqlite3_column_bytes = V.pc)(a, b); + d._sqlite3_column_bytes16 = (a, b) => (d._sqlite3_column_bytes16 = V.qc)(a, b); + d._sqlite3_column_double = (a, b) => (d._sqlite3_column_double = V.rc)(a, b); + d._sqlite3_column_text = (a, b) => (d._sqlite3_column_text = V.sc)(a, b); + d._sqlite3_column_value = (a, b) => (d._sqlite3_column_value = V.tc)(a, b); + d._sqlite3_column_text16 = (a, b) => (d._sqlite3_column_text16 = V.uc)(a, b); + d._sqlite3_column_type = (a, b) => (d._sqlite3_column_type = V.vc)(a, b); + d._sqlite3_column_name = (a, b) => (d._sqlite3_column_name = V.wc)(a, b); + d._sqlite3_column_name16 = (a, b) => (d._sqlite3_column_name16 = V.xc)(a, b); + d._sqlite3_bind_blob = (a, b, c, e, f) => (d._sqlite3_bind_blob = V.yc)(a, b, c, e, f); + d._sqlite3_bind_blob64 = (a, b, c, e, f, h) => (d._sqlite3_bind_blob64 = V.zc)(a, b, c, e, f, h); + d._sqlite3_bind_double = (a, b, c) => (d._sqlite3_bind_double = V.Ac)(a, b, c); + d._sqlite3_bind_int = (a, b, c) => (d._sqlite3_bind_int = V.Bc)(a, b, c); + d._sqlite3_bind_int64 = (a, b, c, e) => (d._sqlite3_bind_int64 = V.Cc)(a, b, c, e); + d._sqlite3_bind_null = (a, b) => (d._sqlite3_bind_null = V.Dc)(a, b); + d._sqlite3_bind_pointer = (a, b, c, e, f) => (d._sqlite3_bind_pointer = V.Ec)(a, b, c, e, f); + d._sqlite3_bind_text = (a, b, c, e, f) => (d._sqlite3_bind_text = V.Fc)(a, b, c, e, f); + d._sqlite3_bind_text64 = (a, b, c, e, f, h, k) => (d._sqlite3_bind_text64 = V.Gc)(a, b, c, e, f, h, k); + d._sqlite3_bind_text16 = (a, b, c, e, f) => (d._sqlite3_bind_text16 = V.Hc)(a, b, c, e, f); + d._sqlite3_bind_value = (a, b, c) => (d._sqlite3_bind_value = V.Ic)(a, b, c); + d._sqlite3_bind_zeroblob = (a, b, c) => (d._sqlite3_bind_zeroblob = V.Jc)(a, b, c); + d._sqlite3_bind_zeroblob64 = (a, b, c, e) => (d._sqlite3_bind_zeroblob64 = V.Kc)(a, b, c, e); + d._sqlite3_bind_parameter_count = (a) => (d._sqlite3_bind_parameter_count = V.Lc)(a); + d._sqlite3_bind_parameter_name = (a, b) => (d._sqlite3_bind_parameter_name = V.Mc)(a, b); + d._sqlite3_bind_parameter_index = (a, b) => (d._sqlite3_bind_parameter_index = V.Nc)(a, b); + d._sqlite3_db_handle = (a) => (d._sqlite3_db_handle = V.Oc)(a); + d._sqlite3_stmt_readonly = (a) => (d._sqlite3_stmt_readonly = V.Pc)(a); + d._sqlite3_stmt_isexplain = (a) => (d._sqlite3_stmt_isexplain = V.Qc)(a); + d._sqlite3_stmt_explain = (a, b) => (d._sqlite3_stmt_explain = V.Rc)(a, b); + d._sqlite3_stmt_busy = (a) => (d._sqlite3_stmt_busy = V.Sc)(a); + d._sqlite3_next_stmt = (a, b) => (d._sqlite3_next_stmt = V.Tc)(a, b); + d._sqlite3_stmt_status = (a, b, c) => (d._sqlite3_stmt_status = V.Uc)(a, b, c); + d._sqlite3_sql = (a) => (d._sqlite3_sql = V.Vc)(a); + d._sqlite3_expanded_sql = (a) => (d._sqlite3_expanded_sql = V.Wc)(a); + d._sqlite3_value_numeric_type = (a) => (d._sqlite3_value_numeric_type = V.Xc)(a); + d._sqlite3_blob_open = (a, b, c, e, f, h, k, n) => (d._sqlite3_blob_open = V.Yc)(a, b, c, e, f, h, k, n); + d._sqlite3_blob_close = (a) => (d._sqlite3_blob_close = V.Zc)(a); + d._sqlite3_blob_read = (a, b, c, e) => (d._sqlite3_blob_read = V._c)(a, b, c, e); + d._sqlite3_blob_write = (a, b, c, e) => (d._sqlite3_blob_write = V.$c)(a, b, c, e); + d._sqlite3_blob_bytes = (a) => (d._sqlite3_blob_bytes = V.ad)(a); + d._sqlite3_blob_reopen = (a, b, c) => (d._sqlite3_blob_reopen = V.bd)(a, b, c); + d._sqlite3_set_authorizer = (a, b, c) => (d._sqlite3_set_authorizer = V.cd)(a, b, c); + d._sqlite3_strglob = (a, b) => (d._sqlite3_strglob = V.dd)(a, b); + d._sqlite3_strlike = (a, b, c) => (d._sqlite3_strlike = V.ed)(a, b, c); + d._sqlite3_exec = (a, b, c, e, f) => (d._sqlite3_exec = V.fd)(a, b, c, e, f); + d._sqlite3_errmsg = (a) => (d._sqlite3_errmsg = V.gd)(a); + d._sqlite3_auto_extension = (a) => (d._sqlite3_auto_extension = V.hd)(a); + d._sqlite3_cancel_auto_extension = (a) => (d._sqlite3_cancel_auto_extension = V.id)(a); + d._sqlite3_reset_auto_extension = () => (d._sqlite3_reset_auto_extension = V.jd)(); + d._sqlite3_prepare = (a, b, c, e, f) => (d._sqlite3_prepare = V.kd)(a, b, c, e, f); + d._sqlite3_prepare_v3 = (a, b, c, e, f, h) => (d._sqlite3_prepare_v3 = V.ld)(a, b, c, e, f, h); + d._sqlite3_prepare16 = (a, b, c, e, f) => (d._sqlite3_prepare16 = V.md)(a, b, c, e, f); + d._sqlite3_prepare16_v2 = (a, b, c, e, f) => (d._sqlite3_prepare16_v2 = V.nd)(a, b, c, e, f); + d._sqlite3_prepare16_v3 = (a, b, c, e, f, h) => (d._sqlite3_prepare16_v3 = V.od)(a, b, c, e, f, h); + d._sqlite3_get_table = (a, b, c, e, f, h) => (d._sqlite3_get_table = V.pd)(a, b, c, e, f, h); + d._sqlite3_free_table = (a) => (d._sqlite3_free_table = V.qd)(a); + d._sqlite3_create_module = (a, b, c, e) => (d._sqlite3_create_module = V.rd)(a, b, c, e); + d._sqlite3_create_module_v2 = (a, b, c, e, f) => (d._sqlite3_create_module_v2 = V.sd)(a, b, c, e, f); + d._sqlite3_drop_modules = (a, b) => (d._sqlite3_drop_modules = V.td)(a, b); + d._sqlite3_declare_vtab = (a, b) => (d._sqlite3_declare_vtab = V.ud)(a, b); + d._sqlite3_vtab_on_conflict = (a) => (d._sqlite3_vtab_on_conflict = V.vd)(a); + d._sqlite3_vtab_config = (a, b, c) => (d._sqlite3_vtab_config = V.wd)(a, b, c); + d._sqlite3_vtab_collation = (a, b) => (d._sqlite3_vtab_collation = V.xd)(a, b); + d._sqlite3_vtab_in = (a, b, c) => (d._sqlite3_vtab_in = V.yd)(a, b, c); + d._sqlite3_vtab_rhs_value = (a, b, c) => (d._sqlite3_vtab_rhs_value = V.zd)(a, b, c); + d._sqlite3_vtab_distinct = (a) => (d._sqlite3_vtab_distinct = V.Ad)(a); + d._sqlite3_keyword_name = (a, b, c) => (d._sqlite3_keyword_name = V.Bd)(a, b, c); + d._sqlite3_keyword_count = () => (d._sqlite3_keyword_count = V.Cd)(); + d._sqlite3_keyword_check = (a, b) => (d._sqlite3_keyword_check = V.Dd)(a, b); + d._sqlite3_complete = (a) => (d._sqlite3_complete = V.Ed)(a); + d._sqlite3_complete16 = (a) => (d._sqlite3_complete16 = V.Fd)(a); + d._sqlite3_libversion = () => (d._sqlite3_libversion = V.Gd)(); + d._sqlite3_libversion_number = () => (d._sqlite3_libversion_number = V.Hd)(); + d._sqlite3_threadsafe = () => (d._sqlite3_threadsafe = V.Id)(); + d._sqlite3_initialize = () => (d._sqlite3_initialize = V.Jd)(); + d._sqlite3_shutdown = () => (d._sqlite3_shutdown = V.Kd)(); + d._sqlite3_config = (a, b) => (d._sqlite3_config = V.Ld)(a, b); + d._sqlite3_db_mutex = (a) => (d._sqlite3_db_mutex = V.Md)(a); + d._sqlite3_db_release_memory = (a) => (d._sqlite3_db_release_memory = V.Nd)(a); + d._sqlite3_db_cacheflush = (a) => (d._sqlite3_db_cacheflush = V.Od)(a); + d._sqlite3_db_config = (a, b, c) => (d._sqlite3_db_config = V.Pd)(a, b, c); + d._sqlite3_last_insert_rowid = (a) => (d._sqlite3_last_insert_rowid = V.Qd)(a); + d._sqlite3_set_last_insert_rowid = (a, b, c) => (d._sqlite3_set_last_insert_rowid = V.Rd)(a, b, c); + d._sqlite3_changes64 = (a) => (d._sqlite3_changes64 = V.Sd)(a); + d._sqlite3_changes = (a) => (d._sqlite3_changes = V.Td)(a); + d._sqlite3_total_changes64 = (a) => (d._sqlite3_total_changes64 = V.Ud)(a); + d._sqlite3_total_changes = (a) => (d._sqlite3_total_changes = V.Vd)(a); + d._sqlite3_txn_state = (a, b) => (d._sqlite3_txn_state = V.Wd)(a, b); + d._sqlite3_close = (a) => (d._sqlite3_close = V.Xd)(a); + d._sqlite3_close_v2 = (a) => (d._sqlite3_close_v2 = V.Yd)(a); + d._sqlite3_busy_handler = (a, b, c) => (d._sqlite3_busy_handler = V.Zd)(a, b, c); + d._sqlite3_progress_handler = (a, b, c, e) => (d._sqlite3_progress_handler = V._d)(a, b, c, e); + d._sqlite3_busy_timeout = (a, b) => (d._sqlite3_busy_timeout = V.$d)(a, b); + d._sqlite3_interrupt = (a) => (d._sqlite3_interrupt = V.ae)(a); + d._sqlite3_is_interrupted = (a) => (d._sqlite3_is_interrupted = V.be)(a); + d._sqlite3_create_function = (a, b, c, e, f, h, k, n) => (d._sqlite3_create_function = V.ce)(a, b, c, e, f, h, k, n); + d._sqlite3_create_function_v2 = (a, b, c, e, f, h, k, n, l) => (d._sqlite3_create_function_v2 = V.de)(a, b, c, e, f, h, k, n, l); + d._sqlite3_create_window_function = (a, b, c, e, f, h, k, n, l, m) => (d._sqlite3_create_window_function = V.ee)(a, b, c, e, f, h, k, n, l, m); + d._sqlite3_create_function16 = (a, b, c, e, f, h, k, n) => (d._sqlite3_create_function16 = V.fe)(a, b, c, e, f, h, k, n); + d._sqlite3_overload_function = (a, b, c) => (d._sqlite3_overload_function = V.ge)(a, b, c); + d._sqlite3_trace_v2 = (a, b, c, e) => (d._sqlite3_trace_v2 = V.he)(a, b, c, e); + d._sqlite3_commit_hook = (a, b, c) => (d._sqlite3_commit_hook = V.ie)(a, b, c); + d._sqlite3_update_hook = (a, b, c) => (d._sqlite3_update_hook = V.je)(a, b, c); + d._sqlite3_rollback_hook = (a, b, c) => (d._sqlite3_rollback_hook = V.ke)(a, b, c); + d._sqlite3_autovacuum_pages = (a, b, c, e) => (d._sqlite3_autovacuum_pages = V.le)(a, b, c, e); + d._sqlite3_wal_autocheckpoint = (a, b) => (d._sqlite3_wal_autocheckpoint = V.me)(a, b); + d._sqlite3_wal_hook = (a, b, c) => (d._sqlite3_wal_hook = V.ne)(a, b, c); + d._sqlite3_wal_checkpoint_v2 = (a, b, c, e, f) => (d._sqlite3_wal_checkpoint_v2 = V.oe)(a, b, c, e, f); + d._sqlite3_wal_checkpoint = (a, b) => (d._sqlite3_wal_checkpoint = V.pe)(a, b); + d._sqlite3_error_offset = (a) => (d._sqlite3_error_offset = V.qe)(a); + d._sqlite3_errmsg16 = (a) => (d._sqlite3_errmsg16 = V.re)(a); + d._sqlite3_errcode = (a) => (d._sqlite3_errcode = V.se)(a); + d._sqlite3_extended_errcode = (a) => (d._sqlite3_extended_errcode = V.te)(a); + d._sqlite3_system_errno = (a) => (d._sqlite3_system_errno = V.ue)(a); + d._sqlite3_errstr = (a) => (d._sqlite3_errstr = V.ve)(a); + d._sqlite3_limit = (a, b, c) => (d._sqlite3_limit = V.we)(a, b, c); + d._sqlite3_open = (a, b) => (d._sqlite3_open = V.xe)(a, b); + d._sqlite3_open_v2 = (a, b, c, e) => (d._sqlite3_open_v2 = V.ye)(a, b, c, e); + d._sqlite3_open16 = (a, b) => (d._sqlite3_open16 = V.ze)(a, b); + d._sqlite3_create_collation = (a, b, c, e, f) => (d._sqlite3_create_collation = V.Ae)(a, b, c, e, f); + d._sqlite3_create_collation_v2 = (a, b, c, e, f, h) => (d._sqlite3_create_collation_v2 = V.Be)(a, b, c, e, f, h); + d._sqlite3_create_collation16 = (a, b, c, e, f) => (d._sqlite3_create_collation16 = V.Ce)(a, b, c, e, f); + d._sqlite3_collation_needed = (a, b, c) => (d._sqlite3_collation_needed = V.De)(a, b, c); + d._sqlite3_collation_needed16 = (a, b, c) => (d._sqlite3_collation_needed16 = V.Ee)(a, b, c); + d._sqlite3_get_clientdata = (a, b) => (d._sqlite3_get_clientdata = V.Fe)(a, b); + d._sqlite3_set_clientdata = (a, b, c, e) => (d._sqlite3_set_clientdata = V.Ge)(a, b, c, e); + d._sqlite3_get_autocommit = (a) => (d._sqlite3_get_autocommit = V.He)(a); + d._sqlite3_table_column_metadata = (a, b, c, e, f, h, k, n, l) => (d._sqlite3_table_column_metadata = V.Ie)(a, b, c, e, f, h, k, n, l); + d._sqlite3_sleep = (a) => (d._sqlite3_sleep = V.Je)(a); + d._sqlite3_extended_result_codes = (a, b) => (d._sqlite3_extended_result_codes = V.Ke)(a, b); + d._sqlite3_file_control = (a, b, c, e) => (d._sqlite3_file_control = V.Le)(a, b, c, e); + d._sqlite3_test_control = (a, b) => (d._sqlite3_test_control = V.Me)(a, b); + d._sqlite3_create_filename = (a, b, c, e, f) => (d._sqlite3_create_filename = V.Ne)(a, b, c, e, f); + d._sqlite3_free_filename = (a) => (d._sqlite3_free_filename = V.Oe)(a); + d._sqlite3_uri_parameter = (a, b) => (d._sqlite3_uri_parameter = V.Pe)(a, b); + d._sqlite3_uri_key = (a, b) => (d._sqlite3_uri_key = V.Qe)(a, b); + d._sqlite3_uri_boolean = (a, b, c) => (d._sqlite3_uri_boolean = V.Re)(a, b, c); + d._sqlite3_uri_int64 = (a, b, c, e) => (d._sqlite3_uri_int64 = V.Se)(a, b, c, e); + d._sqlite3_filename_database = (a) => (d._sqlite3_filename_database = V.Te)(a); + d._sqlite3_filename_journal = (a) => (d._sqlite3_filename_journal = V.Ue)(a); + d._sqlite3_filename_wal = (a) => (d._sqlite3_filename_wal = V.Ve)(a); + d._sqlite3_db_name = (a, b) => (d._sqlite3_db_name = V.We)(a, b); + d._sqlite3_db_filename = (a, b) => (d._sqlite3_db_filename = V.Xe)(a, b); + d._sqlite3_db_readonly = (a, b) => (d._sqlite3_db_readonly = V.Ye)(a, b); + d._sqlite3_compileoption_used = (a) => (d._sqlite3_compileoption_used = V.Ze)(a); + d._sqlite3_compileoption_get = (a) => (d._sqlite3_compileoption_get = V._e)(a); + d._sqlite3_sourceid = () => (d._sqlite3_sourceid = V.$e)(); + var pd = () => (pd = V.af)(), + Vb = (d._malloc = (a) => (Vb = d._malloc = V.bf)(a)), + ed = (d._free = (a) => (ed = d._free = V.cf)(a)); + d._RegisterExtensionFunctions = (a) => (d._RegisterExtensionFunctions = V.df)(a); + d._set_authorizer = (a) => (d._set_authorizer = V.ef)(a); + d._create_function = (a, b, c, e, f, h) => (d._create_function = V.ff)(a, b, c, e, f, h); + d._create_module = (a, b, c, e) => (d._create_module = V.gf)(a, b, c, e); + d._progress_handler = (a, b) => (d._progress_handler = V.hf)(a, b); + d._register_vfs = (a, b, c, e) => (d._register_vfs = V.jf)(a, b, c, e); + d._getSqliteFree = () => (d._getSqliteFree = V.kf)(); + var rd = (d._main = (a, b) => (rd = d._main = V.lf)(a, b)), + fb = (a, b) => (fb = V.nf)(a, b), + sd = () => (sd = V.of)(), + nd = () => (nd = V.pf)(), + ld = (a) => (ld = V.qf)(a), + md = (a) => (md = V.rf)(a), + cd = (a) => (cd = V.sf)(a), + Sc = () => (Sc = V.tf)(), + bd = (a) => (bd = V.uf)(a), + dd = () => (dd = V.vf)(); + d._sqlite3_version = 3232; + d.getTempRet0 = sd; + d.ccall = Z; + d.cwrap = (a, b, c, e) => { + var f = !c || c.every((h) => 'number' === h || 'boolean' === h); + return 'string' !== b && f && !e ? d['_' + a] : () => Z(a, b, c, arguments, e); + }; + d.addFunction = (a, b) => { + if (!jd) { + jd = new WeakMap(); + var c = hd.length; + if (jd) + for (var e = 0; e < 0 + c; e++) { + var f = hd.get(e); + f && jd.set(f, e); + } + } + if ((c = jd.get(a) || 0)) return c; + if (kd.length) c = kd.pop(); + else { + try { + hd.grow(1); + } catch (n) { + if (!(n instanceof RangeError)) throw n; + throw 'Unable to grow wasm table. Set ALLOW_TABLE_GROWTH.'; + } + c = hd.length - 1; + } + try { + hd.set(c, a); + } catch (n) { + if (!(n instanceof TypeError)) throw n; + if ('function' == typeof WebAssembly.Function) { + e = WebAssembly.Function; + f = { i: 'i32', j: 'i64', f: 'f32', d: 'f64', e: 'externref', p: 'i32' }; + for (var h = { parameters: [], results: 'v' == b[0] ? [] : [f[b[0]]] }, k = 1; k < b.length; ++k) h.parameters.push(f[b[k]]); + b = new e(h, a); + } else { + e = [1]; + f = b.slice(0, 1); + b = b.slice(1); + h = { i: 127, p: 127, j: 126, f: 125, d: 124, e: 111 }; + e.push(96); + k = b.length; + 128 > k ? e.push(k) : e.push((k % 128) | 128, k >> 7); + for (k = 0; k < b.length; ++k) e.push(h[b[k]]); + 'v' == f ? e.push(0) : e.push(1, h[f]); + b = [0, 97, 115, 109, 1, 0, 0, 0, 1]; + f = e.length; + 128 > f ? b.push(f) : b.push((f % 128) | 128, f >> 7); + b.push.apply(b, e); + b.push(2, 7, 1, 1, 101, 1, 102, 0, 0, 7, 5, 1, 1, 102, 0, 0); + b = new WebAssembly.Module(new Uint8Array(b)); + b = new WebAssembly.Instance(b, { e: { f: a } }).exports.f; + } + hd.set(c, b); + } + jd.set(a, c); + return c; + }; + d.setValue = J; + d.getValue = H; + d.UTF8ToString = (a, b) => (a ? K(x, a, b) : ''); + d.stringToUTF8 = (a, b, c) => Ta(a, x, b, c); + d.lengthBytesUTF8 = Sa; + d.intArrayFromString = Ua; + d.intArrayToString = (a) => { + for (var b = [], c = 0; c < a.length; c++) { + var e = a[c]; + 255 < e && (e &= 255); + b.push(String.fromCharCode(e)); + } + return b.join(''); + }; + d.AsciiToString = (a) => { + for (var b = ''; ; ) { + var c = x[a++ >> 0]; + if (!c) return b; + b += String.fromCharCode(c); + } + }; + d.UTF16ToString = (a, b) => { + var c = a >> 1; + for (var e = c + b / 2; !(c >= e) && oa[c]; ) ++c; + c <<= 1; + if (32 < c - a && od) return od.decode(x.subarray(a, c)); + c = ''; + for (e = 0; !(e >= b / 2); ++e) { + var f = z[(a + 2 * e) >> 1]; + if (0 == f) break; + c += String.fromCharCode(f); + } + return c; + }; + d.stringToUTF16 = (a, b, c) => { + void 0 === c && (c = 2147483647); + if (2 > c) return 0; + c -= 2; + var e = b; + c = c < 2 * a.length ? c / 2 : a.length; + for (var f = 0; f < c; ++f) (z[b >> 1] = a.charCodeAt(f)), (b += 2); + z[b >> 1] = 0; + return b - e; + }; + d.UTF32ToString = (a, b) => { + for (var c = 0, e = ''; !(c >= b / 4); ) { + var f = A[(a + 4 * c) >> 2]; + if (0 == f) break; + ++c; + 65536 <= f ? ((f -= 65536), (e += String.fromCharCode(55296 | (f >> 10), 56320 | (f & 1023)))) : (e += String.fromCharCode(f)); + } + return e; + }; + d.stringToUTF32 = (a, b, c) => { + void 0 === c && (c = 2147483647); + if (4 > c) return 0; + var e = b; + c = e + c - 4; + for (var f = 0; f < a.length; ++f) { + var h = a.charCodeAt(f); + if (55296 <= h && 57343 >= h) { + var k = a.charCodeAt(++f); + h = (65536 + ((h & 1023) << 10)) | (k & 1023); + } + A[b >> 2] = h; + b += 4; + if (b + 4 > c) break; + } + A[b >> 2] = 0; + return b - e; + }; + d.writeArrayToMemory = (a, b) => { + w.set(a, b); + }; + var td; + za = function ud() { + td || vd(); + td || (za = ud); + }; + function vd() { + function a() { + if (!td && ((td = !0), (d.calledRun = !0), !v)) { + d.noFSInit || + Lb || + ((Lb = !0), + Kb(), + (d.stdin = d.stdin), + (d.stdout = d.stdout), + (d.stderr = d.stderr), + d.stdin ? Mb('stdin', d.stdin) : Bb('/dev/tty', '/dev/stdin'), + d.stdout ? Mb('stdout', null, d.stdout) : Bb('/dev/tty', '/dev/stdout'), + d.stderr ? Mb('stderr', null, d.stderr) : Bb('/dev/tty1', '/dev/stderr'), + Hb('/dev/stdin', 0), + Hb('/dev/stdout', 1), + Hb('/dev/stderr', 1)); + lb = !1; + Ia(ta); + Ia(ua); + aa(d); + if (d.onRuntimeInitialized) d.onRuntimeInitialized(); + if (wd) { + var b = rd; + try { + var c = b(0, 0); + na = c; + Nc(c); + } catch (e) { + Oc(e); + } + } + if (d.postRun) for ('function' == typeof d.postRun && (d.postRun = [d.postRun]); d.postRun.length; ) (b = d.postRun.shift()), va.unshift(b); + Ia(va); + } + } + if (!(0 < xa)) { + if (d.preRun) for ('function' == typeof d.preRun && (d.preRun = [d.preRun]); d.preRun.length; ) wa(); + Ia(sa); + 0 < xa || + (d.setStatus + ? (d.setStatus('Running...'), + setTimeout(() => { + setTimeout(() => { + d.setStatus(''); + }, 1); + a(); + }, 1)) + : a()); + } + } + if (d.preInit) for ('function' == typeof d.preInit && (d.preInit = [d.preInit]); 0 < d.preInit.length; ) d.preInit.pop()(); + var wd = !0; + d.noInitialRun && (wd = !1); + vd(); + + return moduleArg.ready; + }; +})(); +export default Module; diff --git a/frontend/public/wa-sqlite-async.wasm b/frontend/public/wa-sqlite-async.wasm new file mode 100755 index 000000000..eb39a57bf Binary files /dev/null and b/frontend/public/wa-sqlite-async.wasm differ diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 000000000..c84ab98ad --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,76 @@ +import { apiClient, handleResponse } from '.'; + +// Oath endpoints +export const githubSignInUrl = apiClient.auth.github.$url().href; +export const googleSignInUrl = apiClient.auth.google.$url().href; +export const microsoftSignInUrl = apiClient.auth.microsoft.$url().href; + +// Sign up a user with the provided email and password +export const signUp = async ({ email, password, token }: { email: string; password: string; token?: string }) => { + const response = await apiClient.auth['sign-up'].$post({ + json: { email, password, token }, + }); + + const json = await handleResponse(response); + return json.success; +}; + +// Check if email exists +export const checkEmail = async (email: string) => { + const response = await apiClient.auth['check-email'].$post({ + json: { email }, + }); + + const json = await handleResponse(response); + return json.success; +}; + +// Verify the user's email with token sent by email +export const verifyEmail = async ({ token, resend }: { token: string; resend?: boolean }) => { + const response = await apiClient.auth['verify-email'].$post({ + json: { token }, + query: { resend: String(resend) }, + }); + + await handleResponse(response); +}; + +// Sign in a user with email and password +export const signIn = async ({ email, password, token }: { email: string; password: string; token?: string }) => { + const response = await apiClient.auth['sign-in'].$post({ + json: { email, password, token }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Send a verification email +export const sendVerificationEmail = async (email: string) => { + const response = await apiClient.auth['send-verification-email'].$post({ + json: { email }, + }); + + await handleResponse(response); +}; + +// Send a reset password email +export const sendResetPasswordEmail = async (email: string) => { + const response = await apiClient.auth['reset-password'].$post({ + json: { email }, + }); + + await handleResponse(response); +}; + +// Reset the user's password +export const resetPassword = async ({ token, password }: { token: string; password: string }) => { + const response = await apiClient.auth['reset-password'][':token'].$post({ + param: { token }, + json: { password }, + }); + + await handleResponse(response); +}; + +export const signOut = () => apiClient.auth['sign-out'].$get(); diff --git a/frontend/src/api/general.ts b/frontend/src/api/general.ts new file mode 100644 index 000000000..4437a7bcb --- /dev/null +++ b/frontend/src/api/general.ts @@ -0,0 +1,145 @@ +import type { Entity } from 'backend/types/common'; +import type { OauthProviderOptions } from '~/modules/auth/oauth-options'; +import { UploadType, type ContextEntity, type UploadParams, type User } from '~/types'; +import { apiClient, handleResponse } from '.'; + +// Get public counts for about page +export const getPublicCounts = async () => { + const response = await apiClient.public.counts.$get(); + + const json = await handleResponse(response); + return json.data; +}; + +// Get upload token to securely upload files with imado: https://imado.eu +export const getUploadToken = async ( + type: UploadType, + query: UploadParams = { public: false, organizationId: undefined }, +) => { + const id = query.organizationId; + + if (!id && type === UploadType.Organization) + return console.error('Organization id required for organization uploads'); + + if (id && type === UploadType.Personal) return console.error('Personal uploads should be typed as personal'); + + const preparedQuery = { + public: String(query.public), + organizationId: id, + }; + + const response = await apiClient['upload-token'].$get({ query: preparedQuery }); + + const json = await handleResponse(response); + return json.data; +}; + +export interface SystemInviteProps { + emails: string[]; + role: User['role']; +} + +// Invite users +export const invite = async (values: SystemInviteProps) => { + const response = await apiClient.invite.$post({ + json: values, + }); + + await handleResponse(response); +}; + +// Check if slug is available +export const checkSlugAvailable = async (params: { slug: string }) => { + const response = await apiClient['check-slug'].$post({ + json: params, + }); + + const json = await handleResponse(response); + return json.success; +}; + +// Check token validation +export const checkToken = async (token: string) => { + const response = await apiClient['check-token'].$post({ + json: { token }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Get suggestions +export const getSuggestions = async (query: string, type?: Entity | undefined) => { + const response = await apiClient.suggestions.$get({ + query: { q: query, type }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +interface AcceptInviteProps { + token: string; + password?: string; + oauth?: OauthProviderOptions | undefined; +} + +// Accept an invitation +export const acceptInvite = async ({ token, password, oauth }: AcceptInviteProps) => { + const response = await apiClient.invite[':token'].$post({ + param: { token }, + json: { password, oauth }, + }); + + const json = await handleResponse(response); + return json.success; +}; + +type RequiredGetMembersParams = { + idOrSlug: string; + entityType: ContextEntity; +}; + +type OptionalGetMembersParams = Partial< + Omit['0']['query'], 'limit' | 'offset'> +> & { + limit?: number; + offset?: number; + page?: number; +}; + +// Combined type +export type GetMembersParams = RequiredGetMembersParams & OptionalGetMembersParams; + +// Get a list of members in an entity +export const getMembers = async ( + { idOrSlug, entityType, q, sort = 'id', order = 'asc', role, page = 0, limit = 50, offset }: GetMembersParams, + signal?: AbortSignal, +) => { + const response = await apiClient.members.$get( + { + query: { + idOrSlug, + entityType, + q, + sort, + order, + offset: typeof offset === 'number' ? String(offset) : String(page * limit), + limit: String(limit), + role, + }, + }, + { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + return fetch(input, { + ...init, + credentials: 'include', + signal, + }); + }, + }, + ); + + const json = await handleResponse(response); + return json.data; +}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 000000000..4268f0b61 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,57 @@ +import type { ErrorType } from 'backend/lib/errors'; +import type { AppType } from 'backend/server'; +import type { Entity } from 'backend/types/common'; + +import { config } from 'config'; +import { type ClientResponse, hc } from 'hono/client'; + +// Custom error class to handle API errors +export class ApiError extends Error { + status: string | number; + type?: string; + entityType?: Entity; + severity?: string; + logId?: string; + path?: string; + method?: string; + timestamp?: string; + usr?: string; + org?: string; + + constructor(error: ErrorType) { + super(error.message); + this.status = error.status; + this.type = error.type; + this.entityType = error.entityType; + this.severity = error.severity; + this.logId = error.logId; + this.path = error.path; + this.method = error.method; + this.timestamp = error.timestamp; + this.usr = error.usr; + this.org = error.org; + } +} + +// biome-ignore lint/suspicious/noExplicitAny: any is used to handle any type of response +export const handleResponse = async , U extends ClientResponse>(response: U) => { + if (response.ok) { + const json = await response.json(); + return json as Awaited['json']>>; + } + + const json = await response.json(); + if ('error' in json) throw new ApiError(json.error); + throw new Error('Unknown error'); +}; + +const clientConfig = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, { + ...init, + credentials: 'include', + }), +}; + +// Create Hono clients to make requests to the backend +export const apiClient = hc(config.backendUrl, clientConfig); diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts new file mode 100644 index 000000000..a37181321 --- /dev/null +++ b/frontend/src/api/me.ts @@ -0,0 +1,45 @@ +import { apiClient, handleResponse } from '.'; +import type { UpdateUserParams } from './users'; + +const client = apiClient.me; + +// Get current user +export const getSelf = async () => { + const response = await client.$get(); + + const json = await handleResponse(response); + return json.data; +}; + +// Get current user menu +export const getUserMenu = async () => { + const response = await client.menu.$get(); + + const json = await handleResponse(response); + return json.data; +}; + +// Update self +export const updateSelf = async (params: Omit) => { + const response = await client.$put({ + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Delete self +export const deleteSelf = async () => { + const response = await client.$delete(); + await handleResponse(response); +}; + +// Terminate user sessions +export const deleteMySessions = async (sessionIds: string[]) => { + const response = await client.sessions.$delete({ + query: { ids: sessionIds }, + }); + + await handleResponse(response); +}; diff --git a/frontend/src/api/memberships.ts b/frontend/src/api/memberships.ts new file mode 100644 index 000000000..fefc5c2dd --- /dev/null +++ b/frontend/src/api/memberships.ts @@ -0,0 +1,44 @@ +import type { ContextEntity, Membership } from '~/types'; +import { apiClient, handleResponse } from '.'; + +const client = apiClient.memberships; + +export interface InviteMemberProps { + emails: string[]; + role: Membership['role']; + idOrSlug: string; + organizationId: string; + entityType: ContextEntity; +} + +// Invite users +export const inviteMembers = async ({ idOrSlug, entityType, organizationId, ...rest }: InviteMemberProps) => { + const response = await client.$post({ + query: { idOrSlug, organizationId, entityType }, + json: rest, + }); + + await handleResponse(response); +}; + +export const removeMembers = async ({ idOrSlug, entityType, ids }: { idOrSlug: string; ids: string[]; entityType: ContextEntity }) => { + const response = await client.$delete({ + query: { idOrSlug, entityType, ids }, + }); + + await handleResponse(response); +}; +export type UpdateMenuOptionsProp = { membershipId: string; role?: Membership['role']; archive?: boolean; muted?: boolean; order?: number }; + +export const updateMembership = async (values: UpdateMenuOptionsProp) => { + const { membershipId, role, archive, muted, order } = values; + const response = await client[':id'].$put({ + param: { + id: membershipId, + }, + json: { role, inactive: archive, muted, order }, + }); + + const json = await handleResponse(response); + return json.data; +}; diff --git a/frontend/src/api/organizations.ts b/frontend/src/api/organizations.ts new file mode 100644 index 000000000..5572485fa --- /dev/null +++ b/frontend/src/api/organizations.ts @@ -0,0 +1,100 @@ +import { apiClient, handleResponse } from '.'; + +const client = apiClient.organizations; + +export type CreateOrganizationParams = Parameters<(typeof client)['$post']>['0']['json']; + +// Create a new organization +export const createOrganization = async (params: CreateOrganizationParams) => { + const response = await client.$post({ + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Get an organization by slug or ID +export const getOrganization = async (idOrSlug: string) => { + const response = await client[':idOrSlug'].$get({ + param: { idOrSlug }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +export type GetOrganizationsParams = Partial< + Omit['0']['query'], 'limit' | 'offset'> & { + limit?: number; + offset?: number; + page?: number; + } +>; + +// Get a list of organizations +export const getOrganizations = async ( + { q, sort = 'id', order = 'asc', page = 0, limit = 50, offset }: GetOrganizationsParams = {}, + signal?: AbortSignal, +) => { + const response = await client.$get( + { + query: { + q, + sort, + order, + offset: typeof offset === 'number' ? String(offset) : String(page * limit), + limit: String(limit), + }, + }, + { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + return fetch(input, { + ...init, + credentials: 'include', + signal, + }); + }, + }, + ); + + const json = await handleResponse(response); + return json.data; +}; + +export type UpdateOrganizationParams = Parameters<(typeof client)[':idOrSlug']['$put']>['0']['json']; + +// Update an organization +export const updateOrganization = async (idOrSlug: string, params: UpdateOrganizationParams) => { + const response = await client[':idOrSlug'].$put({ + param: { idOrSlug }, + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Delete organizations +export const deleteOrganizations = async (ids: string[]) => { + const response = await client.$delete({ + query: { ids }, + }); + + await handleResponse(response); +}; + +// INFO: Send newsletter to organizations (not implemented) +export const sendNewsletter = async ({ + organizationIds, + subject, + content, +}: { + organizationIds: string[]; + subject: string; + content: string; +}) => { + console.info('Sending newsletter to organizations', organizationIds, subject, content); + + return { success: true }; +}; diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts new file mode 100644 index 000000000..3ba1d9a80 --- /dev/null +++ b/frontend/src/api/projects.ts @@ -0,0 +1,101 @@ +import { apiClient, handleResponse } from '.'; + +const client = apiClient.projects; + +export type CreateProjectParams = Parameters<(typeof client)['$post']>['0']['json'] & { + organizationId: string; +}; + +// Create a new project +export const createProject = async (workspaceId: string, { ...rest }: CreateProjectParams) => { + const response = await client.$post({ + query: { workspaceId }, + json: rest, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Get an project by its slug or ID +export const getProject = async (idOrSlug: string) => { + const response = await client[':idOrSlug'].$get({ + param: { idOrSlug }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +export type GetProjectsParams = Partial< + Omit['0']['query'], 'limit' | 'offset'> & { + limit?: number; + offset?: number; + page?: number; + } +>; + +// Get a list of projects +export const getProjects = async ( + { + q, + sort = 'id', + order = 'asc', + page = 0, + limit = 50, + workspaceId, + organizationId, + requestedUserId, + offset + }: GetProjectsParams = {}, + signal?: AbortSignal, +) => { + const response = await client.$get( + { + query: { + q, + sort, + order, + offset: typeof offset === 'number' ? String(offset) : String(page * limit), + limit: String(limit), + workspaceId, + organizationId, + requestedUserId, + }, + }, + { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + return fetch(input, { + ...init, + credentials: 'include', + signal, + }); + }, + }, + ); + + const json = await handleResponse(response); + return json.data; +}; + +export type UpdateProjectParams = Parameters<(typeof client)[':idOrSlug']['$put']>['0']['json']; + +// Update a project +export const updateProject = async (idOrSlug: string, params: UpdateProjectParams) => { + const response = await client[':idOrSlug'].$put({ + param: { idOrSlug }, + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Delete projects +export const deleteProjects = async (ids: string[]) => { + const response = await client.$delete({ + query: { ids }, + }); + + await handleResponse(response); +}; diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts new file mode 100644 index 000000000..985c97757 --- /dev/null +++ b/frontend/src/api/requests.ts @@ -0,0 +1,50 @@ +import { apiClient, handleResponse } from '.'; +import type { RequestProp } from '~/types'; + +const client = apiClient.requests; + +// Request access or request info +export const createRequest = async (requestInfo: RequestProp) => { + const response = await client.$post({ + json: requestInfo, + }); + + await handleResponse(response); +}; + +export type GetRequestsParams = Partial< + Omit['0']['query'], 'limit' | 'offset'> & { + limit?: number; + offset?: number; + page?: number; + } +>; + +export const getRequests = async ( + { q, sort = 'id', order = 'asc', page = 0, limit = 50, offset }: GetRequestsParams = {}, + signal?: AbortSignal, +) => { + const response = await client.$get( + { + query: { + q, + sort, + order, + offset: typeof offset === 'number' ? String(offset) : String(page * limit), + limit: String(limit), + }, + }, + { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + return fetch(input, { + ...init, + credentials: 'include', + signal, + }); + }, + }, + ); + + const json = await handleResponse(response); + return json.data; +}; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 000000000..9a5476bbe --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,74 @@ +import { apiClient, handleResponse } from '.'; + +const client = apiClient.users; + +// Get user by slug or ID +export const getUser = async (idOrSlug: string) => { + const response = await client[':idOrSlug'].$get({ + param: { idOrSlug }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +export type GetUsersParams = Partial< + Omit['0']['query'], 'limit' | 'offset'> & { + limit?: number; + offset?: number; + page?: number; + } +>; + +// Get a list of users in system +export const getUsers = async ( + { q, sort = 'id', order = 'asc', page = 0, limit = 2, role, offset }: GetUsersParams = {}, + signal?: AbortSignal, +) => { + const response = await client.$get( + { + query: { + q, + sort, + order, + role, + offset: typeof offset === 'number' ? String(offset) : String(page * limit), + limit: String(limit), + }, + }, + { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + return fetch(input, { + ...init, + credentials: 'include', + signal, + }); + }, + }, + ); + + const json = await handleResponse(response); + return json.data; +}; + +// Delete users from system +export const deleteUsers = async (userIds: string[]) => { + const response = await client.$delete({ + query: { ids: userIds }, + }); + + await handleResponse(response); +}; + +export type UpdateUserParams = Parameters<(typeof client)[':idOrSlug']['$put']>['0']['json']; + +// Update user +export const updateUser = async (idOrSlug: string, params: UpdateUserParams) => { + const response = await client[':idOrSlug'].$put({ + param: { idOrSlug }, + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts new file mode 100644 index 000000000..8a4fd2b2e --- /dev/null +++ b/frontend/src/api/workspaces.ts @@ -0,0 +1,47 @@ +import { apiClient, handleResponse } from '.'; + +const client = apiClient.workspaces; + +export type CreateWorkspaceParams = Parameters<(typeof client)['$post']>['0']['json']; + +// Create new workspace +export const createWorkspace = async ({ ...rest }: CreateWorkspaceParams) => { + const response = await client.$post({ + json: rest, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Get workspace by its slug or ID +export const getWorkspace = async (idOrSlug: string) => { + const response = await client[':idOrSlug'].$get({ + param: { idOrSlug }, + }); + + const json = await handleResponse(response); + return json.data; +}; + +export type UpdateWorkspaceParams = Parameters<(typeof client)[':idOrSlug']['$put']>['0']['json']; + +// Update workspace +export const updateWorkspace = async (idOrSlug: string, params: UpdateWorkspaceParams) => { + const response = await client[':idOrSlug'].$put({ + param: { idOrSlug }, + json: params, + }); + + const json = await handleResponse(response); + return json.data; +}; + +// Delete workspaces +export const deleteWorkspaces = async (ids: string[]) => { + const response = await client.$delete({ + query: { ids }, + }); + + await handleResponse(response); +}; diff --git a/frontend/src/hooks/use-auto-resize.tsx b/frontend/src/hooks/use-auto-resize.tsx new file mode 100644 index 000000000..a3ced852e --- /dev/null +++ b/frontend/src/hooks/use-auto-resize.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export const useAutoResize = (ref: React.ForwardedRef, autoResize: boolean) => { + const areaRef = React.useRef(null); + + // biome-ignore lint/style/noNonNullAssertion: + React.useImperativeHandle(ref, () => areaRef.current!); + + React.useEffect(() => { + const ref = areaRef?.current; + + const updateAreaHeight = () => { + if (ref && autoResize) { + ref.style.height = 'auto'; + ref.style.height = `${ref ? ref.scrollHeight : 0}px`; + } + }; + + updateAreaHeight(); + + ref?.addEventListener('input', updateAreaHeight); + + return () => ref?.removeEventListener('input', updateAreaHeight); + }, []); + + return { areaRef }; +}; diff --git a/frontend/src/hooks/use-before-unload.tsx b/frontend/src/hooks/use-before-unload.tsx new file mode 100644 index 000000000..d37ed0460 --- /dev/null +++ b/frontend/src/hooks/use-before-unload.tsx @@ -0,0 +1,26 @@ +import { config } from 'config'; +import { useEffect } from 'react'; + +// This hook is used to show a confirmation dialog when the user tries to leave the page with unsaved changes +export const useBeforeUnload = (isChanged: boolean) => { + useEffect(() => { + const message = 'You have unsaved changes. Are you sure you want to leave?'; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isChanged && config.mode === 'development') { + return console.info('Beforeunload warning is triggered but not shown in dev mode.'); + } + + if (isChanged) { + e.preventDefault(); + e.returnValue = message; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isChanged]); +}; diff --git a/frontend/src/hooks/use-breakpoints.tsx b/frontend/src/hooks/use-breakpoints.tsx new file mode 100644 index 000000000..81be31bd9 --- /dev/null +++ b/frontend/src/hooks/use-breakpoints.tsx @@ -0,0 +1,52 @@ +import { config } from 'config'; +import { useEffect, useState } from 'react'; + +type ValidBreakpoints = keyof typeof config.theme.screenSizes; + +// This hook is used to conditionally render components based on the current screen width +export const useBreakpoints = (mustBe: 'min' | 'max', breakpoint: ValidBreakpoints): boolean => { + const breakpoints: { [key: string]: string } = config.theme.screenSizes; + const sortedBreakpoints = Object.keys(breakpoints).sort((a, b) => Number.parseInt(breakpoints[a], 10) - Number.parseInt(breakpoints[b], 10)); + const smallestBreakpoint = sortedBreakpoints[0]; + + const getBreakpoint = () => { + const matchedBreakpoints = sortedBreakpoints.filter((point) => { + const breakpointSize = Number.parseInt(breakpoints[point], 10); + return !Number.isNaN(breakpointSize) && window.innerWidth >= breakpointSize; + }); + + return matchedBreakpoints.pop() || smallestBreakpoint; + }; + + const [currentBreakpoint, setCurrentBreakpoint] = useState(getBreakpoint()); + + useEffect(() => { + let debounceTimeout: ReturnType; + + const checkBreakpoint = () => { + const newBreakpoint = getBreakpoint(); + if (newBreakpoint !== currentBreakpoint) setCurrentBreakpoint(newBreakpoint); + }; + + checkBreakpoint(); + + const debouncedCheckBreakpoint = () => { + clearTimeout(debounceTimeout); + debounceTimeout = setTimeout(checkBreakpoint, 100); + }; + + window.addEventListener('resize', debouncedCheckBreakpoint); + + return () => { + window.removeEventListener('resize', debouncedCheckBreakpoint); + clearTimeout(debounceTimeout); + }; + }, [breakpoints, currentBreakpoint]); + + const currentBreakpointIndex = sortedBreakpoints.indexOf(currentBreakpoint); + const breakpointIndex = sortedBreakpoints.indexOf(breakpoint); + const higher = currentBreakpointIndex > breakpointIndex; + const lower = currentBreakpointIndex < breakpointIndex; + + return (mustBe === 'min' && higher) || (mustBe === 'max' && lower) || (!higher && !lower); +}; diff --git a/frontend/src/hooks/use-copy-to-clipboard.tsx b/frontend/src/hooks/use-copy-to-clipboard.tsx new file mode 100644 index 000000000..db3c8dddc --- /dev/null +++ b/frontend/src/hooks/use-copy-to-clipboard.tsx @@ -0,0 +1,23 @@ +import { useCallback, useState } from 'react'; + +// This hook is used to copy text to the clipboard +export const useCopyToClipboard = (timeoutDuration = 1000) => { + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + const copyToClipboard = useCallback( + async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setError(null); + setTimeout(() => setCopied(false), timeoutDuration); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to copy text')); + } + }, + [timeoutDuration], + ); + + return { copied, error, copyToClipboard }; +}; diff --git a/frontend/src/hooks/use-debounce.tsx b/frontend/src/hooks/use-debounce.tsx new file mode 100644 index 000000000..cb02000bc --- /dev/null +++ b/frontend/src/hooks/use-debounce.tsx @@ -0,0 +1,13 @@ +import { debounce } from '@github/mini-throttle'; +import { useCallback, useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay = 1000): T { + const [debouncedValue, setDebouncedValue] = useState(value); + const debounceFn = useCallback(debounce(setDebouncedValue, delay), []); + + useEffect(() => { + debounceFn(value); + }, [value, delay]); + + return debouncedValue; +} diff --git a/frontend/src/hooks/use-double-click.tsx b/frontend/src/hooks/use-double-click.tsx new file mode 100644 index 000000000..a2f876e3b --- /dev/null +++ b/frontend/src/hooks/use-double-click.tsx @@ -0,0 +1,79 @@ +import { type RefObject, useEffect } from 'react'; + +/** + * A simple React hook for differentiating single and double clicks on the same component. + * + * @param {node} ref Dom node to watch for double clicks + * @param {number} [latency=300] The amount of time (in milliseconds) to wait before differentiating a single from a double click + * @param {function} onSingleClick A callback function for single click events + * @param {function} onDoubleClick A callback function for double click events + * @param {string[]} allowedTargets Set a Lover case element name that allow to be tracked by hook + * @param {string[]} excludeIds Set a element id that excluded from tracking by hook + */ + +interface UseDoubleClickOptions { + ref: RefObject; + allowedTargets?: string[]; + excludeIds?: string[]; + latency?: number; + onSingleClick?: (event: MouseEvent) => void; + onDoubleClick?: (event: MouseEvent) => void; +} + +const useDoubleClick = ({ + ref, + latency = 300, + excludeIds = [], + allowedTargets = [], + onSingleClick = () => null, + onDoubleClick = () => null, +}: UseDoubleClickOptions) => { + useEffect(() => { + const clickRef = ref.current; + if (!clickRef) return; + + let clickCount = 0; + const handleClick = (e: Event) => { + const targetElement = e.target as HTMLElement | null; + + // Ensure targetElement is not null before accessing its properties + if (!targetElement) return; + + let isExcluded = false; + let parentElement: HTMLElement | null = targetElement; + while (parentElement) { + if (excludeIds.includes(parentElement.id)) { + isExcluded = true; + break; + } + parentElement = parentElement.parentElement; + } + + // Ignore the click if it matches an allowed target or if it's excluded + if ((allowedTargets.length > 0 && !allowedTargets.includes(targetElement.localName)) || isExcluded) { + return; + } + + // Update the type of the event parameter to Event + clickCount += 1; + + setTimeout(() => { + if (clickCount === 1) + onSingleClick(e as MouseEvent); // Cast the event to MouseEvent + else if (clickCount === 2) onDoubleClick(e as MouseEvent); // Cast the event to MouseEvent + + clickCount = 0; + }, latency); + }; + + // Add event listener for click events + clickRef.addEventListener('click', handleClick); + + // Remove event listener + return () => { + clickRef.removeEventListener('click', handleClick); + }; + }); +}; + +export default useDoubleClick; diff --git a/frontend/src/hooks/use-draft-form.tsx b/frontend/src/hooks/use-draft-form.tsx new file mode 100644 index 000000000..6d229974a --- /dev/null +++ b/frontend/src/hooks/use-draft-form.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { type FieldValues, type Path, type UseFormProps, type UseFormReturn, useForm } from 'react-hook-form'; +import { useDraftStore } from '~/store/draft'; + +// This hook is used to create a form with unsaved draft support +export function useFormWithDraft< + TFieldValues extends FieldValues = FieldValues, + // biome-ignore lint/suspicious/noExplicitAny: any is required here + TContext = any, + TTransformedValues extends FieldValues = TFieldValues, +>( + formId: string, + props?: UseFormProps, +): UseFormReturn & { + unsavedChanges: boolean; +} { + const form = useForm(props); + const getForm = useDraftStore((state) => state.getForm); + const setForm = useDraftStore((state) => state.setForm); + const resetForm = useDraftStore((state) => state.resetForm); + + const [unsavedChanges, setUnsavedChanges] = useState(false); + + useEffect(() => { + const values = getForm(formId); + + if (values) { + setUnsavedChanges(true); + for (const key in values) { + form.setValue(key as unknown as Path, values[key as keyof TFieldValues]); + } + } + }, [formId]); + + const allFields = form.watch(); + + useEffect(() => { + if (form.formState.isDirty) { + const values = Object.fromEntries(Object.entries(allFields).filter(([_, value]) => value !== undefined)); + if (Object.keys(values).length > 0) { + return setForm(formId, values); + } + } + + if (unsavedChanges) { + setUnsavedChanges(false); + resetForm(formId); + } + }, [allFields, formId]); + + return { + ...form, + unsavedChanges, + reset: (values, keepStateOptions) => { + resetForm(formId); + form.reset(values, keepStateOptions); + }, + }; +} diff --git a/frontend/src/hooks/use-focus-by-id.tsx b/frontend/src/hooks/use-focus-by-id.tsx new file mode 100644 index 000000000..087535397 --- /dev/null +++ b/frontend/src/hooks/use-focus-by-id.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; + +// Custom hook for focusing an element by id +const useFocusById = (id: string) => { + useEffect(() => { + const element = document.getElementById(id) as HTMLElement; + if (!element) return; + element.focus(); + }, [id]); +}; + +export default useFocusById; diff --git a/frontend/src/hooks/use-hide-elements-by-id.tsx b/frontend/src/hooks/use-hide-elements-by-id.tsx new file mode 100644 index 000000000..ae0e59ac3 --- /dev/null +++ b/frontend/src/hooks/use-hide-elements-by-id.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; + +/** + * useHideElementsById - A custom React hook to hide elements by their IDs. + * + * @param {string[]} ids - An array of element IDs to hide. + */ +const useHideElementsById = (ids: string[]): void => { + useEffect(() => { + const hiddenElements: HTMLElement[] = []; + + for (let i = 0; i < ids.length; i++) { + const element = document.getElementById(ids[i]); + if (element) { + element.style.display = 'none'; + hiddenElements.push(element); + } + } + + return () => { + for (let i = 0; i < hiddenElements.length; i++) { + hiddenElements[i].style.display = ''; + } + }; + }, [ids]); +}; + +export default useHideElementsById; diff --git a/frontend/src/hooks/use-hot-keys-helpers.ts b/frontend/src/hooks/use-hot-keys-helpers.ts new file mode 100644 index 000000000..b0a93e4ee --- /dev/null +++ b/frontend/src/hooks/use-hot-keys-helpers.ts @@ -0,0 +1,96 @@ +type KeyboardModifiers = { + alt: boolean; + ctrl: boolean; + meta: boolean; + mod: boolean; + shift: boolean; +}; + +type Hotkey = KeyboardModifiers & { + key?: string; +}; + +type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; + +function parseHotkey(hotkey: string): Hotkey { + const keys = + hotkey === '+' + ? ['+'] + : hotkey + .toLowerCase() + .split('+') + .map((part) => part.trim()); + + const modifiers: KeyboardModifiers = { + alt: keys.includes('alt'), + ctrl: keys.includes('ctrl'), + meta: keys.includes('meta'), + mod: keys.includes('mod'), + shift: keys.includes('shift'), + }; + + const reservedKeys = ['alt', 'ctrl', 'meta', 'shift', 'mod']; + + const freeKey = keys.find((key) => !reservedKeys.includes(key)); + + return { + ...modifiers, + key: freeKey, + }; +} + +function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { + const { alt, ctrl, meta, mod, shift, key } = hotkey; + const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event; + + if (alt !== altKey) { + return false; + } + + if (mod) { + if (!ctrlKey && !metaKey) { + return false; + } + } else { + if (ctrl !== ctrlKey) { + return false; + } + if (meta !== metaKey) { + return false; + } + } + if (shift !== shiftKey) { + return false; + } + + if (key && (pressedKey.toLowerCase() === key.toLowerCase() || event.code.replace('Key', '').toLowerCase() === key.toLowerCase())) { + return true; + } + + return false; +} + +function getHotkeyMatcher(hotkey: string): CheckHotkeyMatch { + return (event) => isExactHotkey(parseHotkey(hotkey), event); +} + +interface HotkeyItemOptions { + preventDefault?: boolean; +} + +type HotkeyItem = [string, (event: KeyboardEvent) => void, HotkeyItemOptions?]; + +function shouldFireEvent(event: KeyboardEvent, tagsToIgnore: string[], triggerOnContentEditable = false) { + if (event.target instanceof HTMLElement) { + if (triggerOnContentEditable) { + return !tagsToIgnore.includes(event.target.tagName); + } + + return !event.target.isContentEditable && !tagsToIgnore.includes(event.target.tagName); + } + + return true; +} + +export { getHotkeyMatcher, shouldFireEvent }; +export type { HotkeyItem }; diff --git a/frontend/src/hooks/use-hot-keys.ts b/frontend/src/hooks/use-hot-keys.ts new file mode 100644 index 000000000..f064fbbb5 --- /dev/null +++ b/frontend/src/hooks/use-hot-keys.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { type HotkeyItem, getHotkeyMatcher, shouldFireEvent } from './use-hot-keys-helpers'; + +export function useHotkeys( + hotkeys: HotkeyItem[], + tagsToIgnore: string[] = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'], + triggerOnContentEditable = false, +) { + React.useEffect(() => { + const keydownListener = (event: KeyboardEvent) => { + const isFormElement = tagsToIgnore.some((tag) => event.target instanceof HTMLElement && event.target.closest(tag)); + if (isFormElement) return; // Ignore if the event target is within a form element + for (const [hotkey, handler, options = { preventDefault: true }] of hotkeys) { + if (getHotkeyMatcher(hotkey)(event) && shouldFireEvent(event, tagsToIgnore, triggerOnContentEditable)) { + if (options.preventDefault) event.preventDefault(); + handler(event); + } + } + }; + + document.documentElement.addEventListener('keydown', keydownListener); + return () => document.documentElement.removeEventListener('keydown', keydownListener); + }, [hotkeys, tagsToIgnore, triggerOnContentEditable]); +} diff --git a/frontend/src/hooks/use-key-press.tsx b/frontend/src/hooks/use-key-press.tsx new file mode 100644 index 000000000..43f8328fc --- /dev/null +++ b/frontend/src/hooks/use-key-press.tsx @@ -0,0 +1,19 @@ +import { useCallback, useEffect } from 'react'; + +// This hook is used to listen for a key press +export const useKeyPress = (targetKey: string, onKeyPress: (event: KeyboardEvent) => void, enable = true): void => { + const handleKeyPress = useCallback( + (event: KeyboardEvent) => { + if (!enable || event.key !== targetKey) return; + onKeyPress(event); + }, + [enable, targetKey, onKeyPress], + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyPress); + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [handleKeyPress]); +}; diff --git a/frontend/src/hooks/use-lazy-component.tsx b/frontend/src/hooks/use-lazy-component.tsx new file mode 100644 index 000000000..fa6c5e814 --- /dev/null +++ b/frontend/src/hooks/use-lazy-component.tsx @@ -0,0 +1,22 @@ +import { type ComponentType, type LazyExoticComponent, lazy, useEffect, useState } from 'react'; + +// Load a component lazily and with a delay +// biome-ignore lint/suspicious/noExplicitAny: Any component can be included +function useLazyComponent>(importFunc: () => Promise<{ default: T }>, delay: number): LazyExoticComponent | null { + const [Component, setComponent] = useState | null>(null); + + useEffect(() => { + const timer = setTimeout(() => { + importFunc().then((module) => { + const LazyComponent = lazy(() => Promise.resolve(module)); + setComponent(LazyComponent); + }); + }, delay); + + return () => clearTimeout(timer); + }, [importFunc, delay]); + + return Component; +} + +export default useLazyComponent; diff --git a/frontend/src/hooks/use-lock-body.tsx b/frontend/src/hooks/use-lock-body.tsx new file mode 100644 index 000000000..48f63990d --- /dev/null +++ b/frontend/src/hooks/use-lock-body.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +// @see https://usehooks.com/useLockBodyScroll. +export function useLockBody() { + React.useLayoutEffect((): (() => void) => { + const originalStyle: string = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); +} diff --git a/frontend/src/hooks/use-map-query-data-to-rows.tsx b/frontend/src/hooks/use-map-query-data-to-rows.tsx new file mode 100644 index 000000000..c44af654e --- /dev/null +++ b/frontend/src/hooks/use-map-query-data-to-rows.tsx @@ -0,0 +1,37 @@ +import { type Dispatch, type SetStateAction, useEffect } from 'react'; + +interface QueryResult { + data?: { + pages?: { + items: T[]; + }[]; + }; +} + +interface UseQueryResultEffectProps { + queryResult: QueryResult; + selectedRows: Set; + setSelectedRows: (selectedRows: Set) => void; + setRows: Dispatch>; +} + +const useMapQueryDataToRows = ({ + queryResult, + selectedRows, + setSelectedRows, + setRows, +}: UseQueryResultEffectProps) => { + useEffect(() => { + const data = queryResult.data?.pages?.flatMap((page) => page.items); + + if (data) { + setSelectedRows(new Set([...selectedRows].filter((id) => data.some((row) => row.id === id)))); + // Reverse the data to remove duplicates from the end because created data is added to the start + const reversedData = [...data].reverse(); + const newRows = data.filter((row, index) => reversedData.findIndex((r) => r.id === row.id) === reversedData.length - 1 - index); + setRows(newRows); + } + }, [queryResult.data]); +}; + +export default useMapQueryDataToRows; diff --git a/frontend/src/hooks/use-measure.tsx b/frontend/src/hooks/use-measure.tsx new file mode 100644 index 000000000..574fc31bd --- /dev/null +++ b/frontend/src/hooks/use-measure.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from 'react'; + +interface MeasureResult { + ref: React.RefObject; + bounds: DOMRectReadOnly; +} + +// Hook to measure the size of an element +export const useMeasure = (): MeasureResult => { + const ref = useRef(null); + const [bounds, setBounds] = useState(new DOMRectReadOnly()); + + useEffect(() => { + let observer: ResizeObserver; + + if (ref.current) { + observer = new ResizeObserver(([entry]) => { + if (entry) { + setBounds(entry.contentRect); + } + }); + observer.observe(ref.current); + } + + return () => { + if (observer) { + observer.disconnect(); + } + }; + }, []); + + return { ref, bounds }; +}; diff --git a/frontend/src/hooks/use-mounted.tsx b/frontend/src/hooks/use-mounted.tsx new file mode 100644 index 000000000..1d4ce182d --- /dev/null +++ b/frontend/src/hooks/use-mounted.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +// This hook is used to fire events when the app has scrolled, mounted (0 ms), started (+ 200ms), and waited (+ 800ms) +export const useMounted = () => { + const [hasMounted, setMounted] = useState(false); + const [hasStarted, setStarted] = useState(false); + const [hasWaited, setWaited] = useState(false); + + useEffect(() => { + setMounted(true); + + const startTimeout = setTimeout(() => setStarted(true), 200); + const readyTimeout = setTimeout(() => setWaited(true), 800); + + return () => { + clearTimeout(startTimeout); + clearTimeout(readyTimeout); + }; + }, []); + + return { + hasMounted, + hasStarted, + hasWaited, + }; +}; + +export default useMounted; diff --git a/frontend/src/hooks/use-mutate-query-data.tsx b/frontend/src/hooks/use-mutate-query-data.tsx new file mode 100644 index 000000000..d42015b6d --- /dev/null +++ b/frontend/src/hooks/use-mutate-query-data.tsx @@ -0,0 +1,149 @@ +import type { InfiniteData, QueryKey } from '@tanstack/react-query'; +import { queryClient } from '~/lib/router'; + +interface Item { + id: string; + membership?: { id: string } | null; +} + +// This hook is used to mutate the data of a query +export const useMutateQueryData = (queryKey: QueryKey) => { + return (items: Item[], action: 'create' | 'update' | 'delete' | 'updateMembership') => { + queryClient.setQueryData<{ + items: Item[]; + total: number; + }>(queryKey, (data) => { + if (!data) return; + + if (action === 'create') { + return { + items: [...items, ...data.items], + total: data.total + items.length, + }; + } + + if (action === 'update') { + return { + items: data.items.map((item) => { + const updatedItem = items.find((items) => items.id === item.id); + if (item.id === updatedItem?.id) { + return updatedItem; + } + return item; + }), + total: data.total, + }; + } + + if (action === 'delete') { + const updatedItems = data.items.filter((item) => !items.some((deletedItem) => deletedItem.id === item.id)); + const updatedTotal = data.total - (data.items.length - updatedItems.length); + return { + items: updatedItems, + total: updatedTotal, + }; + } + + if (action === 'updateMembership') { + return { + items: data.items.map((item) => { + const updatedItem = items.find((items) => item.membership && items.id === item.membership.id); + if (item.membership && item.membership.id === updatedItem?.id) { + return { ...item, membership: { ...item.membership, ...updatedItem } }; + } + return item; + }), + total: data.total, + }; + } + }); + }; +}; + +// This hook is used to mutate the data of an infinite query +export const useMutateInfiniteQueryData = (queryKey: QueryKey, invalidateKeyGetter?: (item: Item) => QueryKey) => { + return (items: Item[], action: 'create' | 'update' | 'delete' | 'updateMembership') => { + queryClient.setQueryData< + InfiniteData<{ + items: Item[]; + total: number; + }> + >(queryKey, (data) => { + if (!data) return; + if (action === 'create') { + return { + pages: [ + { + items: [...items, ...data.pages[0].items], + total: data.pages[0].total + items.length, + }, + ...data.pages.slice(1), + ], + pageParams: data.pageParams, + }; + } + + if (action === 'update') { + const updatedPages = data.pages.map((page) => { + return { + items: page.items.map((item) => { + const updatedItem = items.find((items) => items.id === item.id); + if (item.id === updatedItem?.id) return updatedItem; + return item; + }), + total: page.total, + }; + }); + + return { + pages: updatedPages, + pageParams: data.pageParams, + }; + } + + if (action === 'delete') { + const updatedPages = data.pages.map((page) => { + const updatedItems = page.items.filter((item) => !items.some((deletedItem) => deletedItem.id === item.id)); + const updatedTotal = page.total - (page.items.length - updatedItems.length); + return { + items: updatedItems, + total: updatedTotal, + }; + }); + + return { + pages: updatedPages, + pageParams: data.pageParams, + }; + } + + if (action === 'updateMembership') { + const updatedPages = data.pages.map((page) => { + return { + items: page.items.map((item) => { + const updatedItem = items.find((items) => item.membership && items.id === item.membership.id); + if (item.membership && item.membership.id === updatedItem?.id) { + return { ...item, membership: { ...item.membership, ...updatedItem } }; + } + return item; + }), + total: page.total, + }; + }); + + return { + pages: updatedPages, + pageParams: data.pageParams, + }; + } + }); + + if (invalidateKeyGetter) { + for (const item of items) { + queryClient.invalidateQueries({ + queryKey: invalidateKeyGetter(item), + }); + } + } + }; +}; diff --git a/frontend/src/hooks/use-mutations.tsx b/frontend/src/hooks/use-mutations.tsx new file mode 100644 index 000000000..473ef4ee3 --- /dev/null +++ b/frontend/src/hooks/use-mutations.tsx @@ -0,0 +1,6 @@ +import { type UseMutationOptions, type UseMutationResult, useMutation as useBaseMutation } from '@tanstack/react-query'; +import type { ApiError } from '~/api'; + +export const useMutation = ( + options: UseMutationOptions, +): UseMutationResult => useBaseMutation(options); diff --git a/frontend/src/hooks/use-route-change.tsx b/frontend/src/hooks/use-route-change.tsx new file mode 100644 index 000000000..77a2c6d2c --- /dev/null +++ b/frontend/src/hooks/use-route-change.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; +import router from '~/lib/router'; + +export const useRouteChange = () => { + const [hasChanged, setHasChanged] = useState(false); + const [toLocation, setToLocation] = useState(''); + + useEffect(() => { + const checkHasChanged = router.subscribe('onBeforeLoad', ({ pathChanged, toLocation }) => { + pathChanged && setHasChanged(true); + setToLocation(toLocation.pathname); + }); + + return () => { + checkHasChanged(); + }; + }, []); + + return { hasChanged, toLocation }; +}; diff --git a/frontend/src/hooks/use-save-in-search-params.tsx b/frontend/src/hooks/use-save-in-search-params.tsx new file mode 100644 index 000000000..478aee93a --- /dev/null +++ b/frontend/src/hooks/use-save-in-search-params.tsx @@ -0,0 +1,44 @@ +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { useEffect } from 'react'; + +// This hook is used to save values in the URL search params +const useSaveInSearchParams = (values: Record, defaultValues?: Record) => { + const navigate = useNavigate(); + const params = useParams({ + strict: false, + }); + const currentSearchParams = useSearch({ + strict: false, + }); + + useEffect(() => { + const searchParams = values; + + for (const key in searchParams) { + if ( + (typeof defaultValues?.[key] !== 'undefined' && searchParams[key] === defaultValues?.[key]) || + currentSearchParams[key as keyof typeof currentSearchParams] === searchParams[key] + ) { + delete searchParams[key]; + } + if (searchParams[key] === '') { + searchParams[key] = undefined; + } + } + + if (Object.keys(searchParams).length === 0) { + return; + } + + navigate({ + params, + replace: true, + search: (prev) => ({ + ...prev, + ...searchParams, + }), + }); + }, [values, navigate]); +}; + +export default useSaveInSearchParams; diff --git a/frontend/src/hooks/use-scroll-spy.tsx b/frontend/src/hooks/use-scroll-spy.tsx new file mode 100644 index 000000000..29d6bd068 --- /dev/null +++ b/frontend/src/hooks/use-scroll-spy.tsx @@ -0,0 +1,135 @@ +// import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useRef, useState } from 'react'; + +export const useScrollSpy = ({ sectionIds = [], autoUpdateHash }: { sectionIds: string[]; autoUpdateHash?: boolean }) => { + // const navigate = useNavigate(); + const observer = useRef(null); + + // Maintain a list of intersecting section ids + const intersectingIdsRef = useRef([]); + + // Maintain scroll direction + const scrollDirectionRef = useRef<'down' | 'up'>('down'); + + const [activeHash, setActiveHash] = useState(sectionIds[0]); + + useEffect(() => { + // Initial mounting: scroll to the hash location + const locationHash = location.hash.replace('#', ''); + + if (sectionIds.includes(locationHash) && locationHash !== sectionIds[0]) { + const element = document.getElementById(locationHash); + if (!element) return; + element.scrollIntoView(); + } + document.documentElement.classList.add('scroll-smooth'); + + return () => { + document.documentElement.classList.remove('scroll-smooth'); + }; + }, []); + + useEffect(() => { + const options = { + root: null as Element | null, + rootMargin: '-10% 0% -10% 0%', + threshold: [0.1, 1], + }; + + if (!autoUpdateHash) return; + + // Ensure observer is created only once + if (!observer.current) { + observer.current = new IntersectionObserver((entries) => { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + const id = entry.target.id; + const currentIntersectingIds: string[] = intersectingIdsRef.current; + const index = currentIntersectingIds.indexOf(id); + + if (entry.isIntersecting) { + if (entry.target.id === sectionIds[sectionIds.length - 1] && entry.intersectionRatio === 1) + return setActiveHash(sectionIds[sectionIds.length - 1]); + // Is intersecting and not yet in array + if (index === -1) { + // console.log('entering', entry.target.id, index, intersectingIdsRef.current); + // Use boundingClientRect to determine scroll direction + scrollDirectionRef.current = entry.boundingClientRect.top < 0 ? 'up' : 'down'; + + // Add to array + intersectingIdsRef.current = [...currentIntersectingIds, id]; + } + } else { + // No longer intersecting and still in array + if (index !== -1) { + // console.log('leaving', entry.target.id, index, intersectingIdsRef.current.length); + // Use boundingClientRect to determine scroll direction + scrollDirectionRef.current = entry.boundingClientRect.top < 0 ? 'down' : 'up'; + + // Remove from array + intersectingIdsRef.current = currentIntersectingIds.filter((currentId) => currentId !== id); + } + } + } + + // console.log(entries); + + // Get the current hash and index + // const currentHash = location.hash.replace('#', ''); + // const currentHashIndex = currentHash ? sectionIds.indexOf(currentHash) : 0; + + // Sorted array of intersecting sections + const intersecting = intersectingIdsRef.current.sort((a, b) => sectionIds.indexOf(a) - sectionIds.indexOf(b)); + + let mainSection = intersecting[0]; + + // If two intersecting, we should limit our scope to the one on top of our scroll direction + if (intersecting.length === 2 && scrollDirectionRef.current === 'down') mainSection = intersecting[intersecting.length - 1]; + + const fullyIntersecting = intersecting.slice(1, -1); + + // If three or more intersecting, we should limit our scope to fully intersecting sections + if (intersecting.length > 2) { + mainSection = scrollDirectionRef.current === 'down' ? fullyIntersecting[fullyIntersecting.length - 1] : fullyIntersecting[0]; + } + + // const mainSectionIndex = sectionIds.indexOf(mainSection); + // console.log( + // fullyIntersecting, + // fullyIntersecting.length, + // Math.floor(fullyIntersecting.length / 2), + // scrollDirectionRef.current, + // intersectingIdsRef.current, + // currentHashIndex, + // mainSection, + // mainSectionIndex, + // ); + + // Update the active hash + // console.log('MAINS', mainSection, activeHash) + setActiveHash(mainSection); + + // if ( + // // User scrolls down and the main intersecting section is above the hash location section + // (scrollDirectionRef.current === 'down' && mainSectionIndex > currentHashIndex) || + // // User scrolls up and the main intersecting section is below the hash location section AND current section is NOT the top section + // (scrollDirectionRef.current === 'up' && mainSectionIndex < currentHashIndex) + // ) { + // return navigate({ search: {section: mainSection}, replace: true }); + }, options); + } + + for (let i = 0; i < sectionIds.length; i++) { + const id = sectionIds[i]; + const section = document.getElementById(id); + if (section) observer.current?.observe(section); + } + + return () => { + observer.current?.disconnect(); + }; + }, [sectionIds]); + + return { activeHash }; +}; diff --git a/frontend/src/hooks/use-scroll-to.tsx b/frontend/src/hooks/use-scroll-to.tsx new file mode 100644 index 000000000..0010b1504 --- /dev/null +++ b/frontend/src/hooks/use-scroll-to.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +// This hook is used to scroll to a specific HTML element +const useScrollTo = (scrollToRef: React.RefObject) => { + useEffect(() => { + if (scrollToRef.current) { + window.scrollTo({ + top: scrollToRef.current.offsetTop, + }); + } + }, [scrollToRef]); +}; + +export default useScrollTo; diff --git a/frontend/src/hooks/use-set-document-title.tsx b/frontend/src/hooks/use-set-document-title.tsx new file mode 100644 index 000000000..7ca01e7d6 --- /dev/null +++ b/frontend/src/hooks/use-set-document-title.tsx @@ -0,0 +1,21 @@ +import { useMatches } from '@tanstack/react-router'; +import { config } from 'config'; +import { useEffect } from 'react'; + +// Custom hook for setting document title +export const useSetDocumentTitle = () => { + const matches = useMatches(); + + useEffect(() => { + const breadcrumbPromises = [...matches] + .map((match) => { + const { staticData } = match; + return staticData.pageTitle; + }) + .filter(Boolean); + + void Promise.all(breadcrumbPromises).then((titles) => { + document.title = titles.join(' › ') + (titles.length && ' · ') + config.name; + }); + }, [matches]); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100755 index 000000000..a104beff5 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,269 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 10%; + --card: 0 0% 100%; + --card-foreground: 240 10% 10%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% %; + --primary: 240 6% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 240 6% 20%; + --muted: 240 5% 77.2%; + --muted-foreground: 240 4% 40%; + --accent: 240 5% 95%; + --accent-foreground: 240 6% 10%; + --destructive: 0 85% 40%; + --destructive-foreground: 0 0% 98%; + --border: 240 6% 92%; + --input: 240 6% 92%; + --ring: 240 6% 10%; + --radius: 0.5rem; + --success: 120 100% 30%; + } + + .dark { + --background: 240 10% 9%; + --foreground: 0 0% 95%; + --card: 240 10% 14%; + --card-foreground: 0 0% 95%; + --popover: 240 10% 9%; + --popover-foreground: 0 0% 95%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 16%; + --secondary: 240 3.7% 15%; + --secondary-foreground: 0 0% 95%; + --muted: 240 3.7% 25%; + --muted-foreground: 240 5% 84.9%; + --accent: 240 3.7% 25%; + --accent-foreground: 0 0% 95%; + --destructive: 0 62.8% 50%; + --destructive-foreground: 0 0% 95%; + --border: 240 3.7% 20%; + --input: 240 3.7% 25%; + --ring: 240 4.9% 83.9%; + --success: 120 100% 40%; + } + + .theme-rose.light { + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + } + + .theme-rose.dark { + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 3.7% 15%; + --secondary-foreground: 0 0% 98%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 0 0% 98%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground min-h-full; + } + + @media (max-width: 639px) { + html { + font-size: 18px; + } + + #app-root { + margin: 0; + display: flex; + flex-direction: column; + height: calc(100vh - 4rem); + overflow: hidden; + position: relative; + } + + #app-root > .container { + padding-left: .75rem; + padding-right: 0.75rem; + } + + #app-content { + flex: 1; + height: 100%; + overflow-y: auto; + } + } + + #root { + @apply flex flex-col min-h-full; + } +} + +@layer components { + .menu-item-sub:before { + content: ''; + display: block; + position: absolute; + top: -9px; + left: 27px; + opacity: .5; + z-index: 0; + width: 1px; + height: 14px; + background-color: hsl(var(--foreground)); + } + + .rich-gradient:after { + content: ''; + display: block; + position: absolute; + top: 0; + opacity: 1; + width: 100%; + height: 100%; + z-index: -3; + background-image: + linear-gradient(to bottom left, rgba(0, 0, 0, 0), rgba(255, 199, 147, 0.87)), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgb(57, 160, 251)), + linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgb(195, 120, 241)), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgb(2, 155, 129)); + } + + .dark .rich-gradient:after { + opacity: .8; + background-image: + linear-gradient(to bottom left, rgba(0, 0, 0, 0), rgba(60, 0, 59, 1)), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgba(15, 6, 86, 1)), + linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(184, 144, 0, 1)), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgba(11, 144, 122, 1)); + } + + .rich-gradient:before { + content: ''; + display: block; + position: absolute; + top: 0; + opacity: 1; + width: 100%; + height: 100%; + z-index: -2; + background-image: radial-gradient(circle 90vh at 40% 40%, hsla(var(--background)), rgba(255, 255, 255, 0.5)); + } + + .dark .rich-gradient:before { + opacity: .8; + background-image: radial-gradient(circle 90vh at 40% 40%, hsla(var(--background)), rgba(255, 255, 255, 0)); + } + + .rich-gradient.dark-gradient:after { + opacity: 1; + background: rgba(0, 0, 0, 1); + background-image: + linear-gradient(to bottom left, rgba(0, 0, 0, 0), rgba(255, 255, 255, 0.2)), + linear-gradient(to top left, rgba(0, 0, 0, 0), rgba(49, 49, 230, 0.3)), + linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(156, 39, 228, 0.2)), + linear-gradient(to top right, rgba(0, 0, 0, 0), rgba(254, 185, 24, 0.3)); + } + + .dark .dark-gradient:after { + opacity: .2; + } + + .rich-gradient.dark-gradient:before { + background-image: radial-gradient(circle 120vh at 40% 120%, rgba(59, 17, 37, 0.6), rgba(255, 255, 255, 0)); + } +} + +.outline-glow-button:before { + content: ''; + display: block; + position: absolute; + top: -1px; + left: -1px; + opacity: .5; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -2; + border-radius: 100px; + background: linear-gradient(45deg, rgba(151, 53, 255, 1) -10%, rgba(251, 204, 38, 1) 60%, rgba(210, 35, 82, 1) 100%); +} + +.outline-glow-button:after { + content: ''; + display: block; + position: absolute; + top: -1px; + left: -1px; + filter: blur(6px); + opacity: .4; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -2; + border-radius: 100px; + background: linear-gradient(45deg, rgba(151, 53, 255, 1) -10%, rgba(251, 204, 38, 1) 60%, rgba(210, 35, 82, 1) 100%); +} + +.outline-glow-button:hover::before { + opacity: 1; +} + +.outline-glow-button:hover::after { + opacity: .8; +} + +.outline-glow-button:active::after { + position: absolute; + top: 0px; + left: 0px; + filter: none; + opacity: 1; + width: 100%; + height: 100%; + z-index: -1; + border-radius: 100px; + background: hsla(var(--background)); +} + +.gradient-button:after, +.gradient-button:before { + display: block; + left: 0; + top: 0; + position: absolute; + width: 100%; + height: 100%; +} + +.gradient-button:before { + z-index: -2; + filter: saturate(5); +} + +.gradient-button:after { + background: linear-gradient(to right, + rgb(100, 100, 100), + rgba(204, 204, 204, 0.8) 40%, + rgba(204, 204, 204, 0.8) 55%, + rgb(34, 34, 34)); + opacity: .3; + z-index: -1; +} + +.fill-grid { + block-size: 100%; +} + +/* Hide non-focus-view elements */ +.focus-view #main-app-content *:not(.focus-view-container, .focus-view-container *) { + display: none; +} \ No newline at end of file diff --git a/frontend/src/json/countries.json b/frontend/src/json/countries.json new file mode 100644 index 000000000..5de666f55 --- /dev/null +++ b/frontend/src/json/countries.json @@ -0,0 +1,246 @@ +[ + { "name": "Afghanistan", "code": "AF" }, + { "name": "Åland Islands", "code": "AX" }, + { "name": "Albania", "code": "AL" }, + { "name": "Algeria", "code": "DZ" }, + { "name": "American Samoa", "code": "AS" }, + { "name": "Andorra", "code": "AD" }, + { "name": "Angola", "code": "AO" }, + { "name": "Anguilla", "code": "AI" }, + { "name": "Antarctica", "code": "AQ" }, + { "name": "Antigua and Barbuda", "code": "AG" }, + { "name": "Argentina", "code": "AR" }, + { "name": "Armenia", "code": "AM" }, + { "name": "Aruba", "code": "AW" }, + { "name": "Australia", "code": "AU" }, + { "name": "Austria", "code": "AT" }, + { "name": "Azerbaijan", "code": "AZ" }, + { "name": "Bahamas", "code": "BS" }, + { "name": "Bahrain", "code": "BH" }, + { "name": "Bangladesh", "code": "BD" }, + { "name": "Barbados", "code": "BB" }, + { "name": "Belarus", "code": "BY" }, + { "name": "Belgium", "code": "BE" }, + { "name": "Belize", "code": "BZ" }, + { "name": "Benin", "code": "BJ" }, + { "name": "Bermuda", "code": "BM" }, + { "name": "Bhutan", "code": "BT" }, + { "name": "Bolivia", "code": "BO" }, + { "name": "Bosnia and Herzegovina", "code": "BA" }, + { "name": "Botswana", "code": "BW" }, + { "name": "Bouvet Island", "code": "BV" }, + { "name": "Brazil", "code": "BR" }, + { "name": "British Indian Ocean Territory", "code": "IO" }, + { "name": "Brunei Darussalam", "code": "BN" }, + { "name": "Bulgaria", "code": "BG" }, + { "name": "Burkina Faso", "code": "BF" }, + { "name": "Burundi", "code": "BI" }, + { "name": "Cambodia", "code": "KH" }, + { "name": "Cameroon", "code": "CM" }, + { "name": "Canada", "code": "CA" }, + { "name": "Cape Verde", "code": "CV" }, + { "name": "Cayman Islands", "code": "KY" }, + { "name": "Central African Republic", "code": "CF" }, + { "name": "Chad", "code": "TD" }, + { "name": "Chile", "code": "CL" }, + { "name": "China", "code": "CN" }, + { "name": "Christmas Island", "code": "CX" }, + { "name": "Cocos (Keeling) Islands", "code": "CC" }, + { "name": "Colombia", "code": "CO" }, + { "name": "Comoros", "code": "KM" }, + { "name": "Congo", "code": "CG" }, + { "name": "Congo, The Democratic Republic of the", "code": "CD" }, + { "name": "Cook Islands", "code": "CK" }, + { "name": "Costa Rica", "code": "CR" }, + { "name": "Cote D'Ivoire", "code": "CI" }, + { "name": "Croatia", "code": "HR" }, + { "name": "Cuba", "code": "CU" }, + { "name": "Cyprus", "code": "CY" }, + { "name": "Czech Republic", "code": "CZ" }, + { "name": "Denmark", "code": "DK" }, + { "name": "Djibouti", "code": "DJ" }, + { "name": "Dominica", "code": "DM" }, + { "name": "Dominican Republic", "code": "DO" }, + { "name": "Ecuador", "code": "EC" }, + { "name": "Egypt", "code": "EG" }, + { "name": "El Salvador", "code": "SV" }, + { "name": "Equatorial Guinea", "code": "GQ" }, + { "name": "Eritrea", "code": "ER" }, + { "name": "Estonia", "code": "EE" }, + { "name": "Ethiopia", "code": "ET" }, + { "name": "Falkland Islands (Malvinas)", "code": "FK" }, + { "name": "Faroe Islands", "code": "FO" }, + { "name": "Fiji", "code": "FJ" }, + { "name": "Finland", "code": "FI" }, + { "name": "France", "code": "FR" }, + { "name": "French Guiana", "code": "GF" }, + { "name": "French Polynesia", "code": "PF" }, + { "name": "French Southern Territories", "code": "TF" }, + { "name": "Gabon", "code": "GA" }, + { "name": "Gambia", "code": "GM" }, + { "name": "Georgia", "code": "GE" }, + { "name": "Germany", "code": "DE" }, + { "name": "Ghana", "code": "GH" }, + { "name": "Gibraltar", "code": "GI" }, + { "name": "Greece", "code": "GR" }, + { "name": "Greenland", "code": "GL" }, + { "name": "Grenada", "code": "GD" }, + { "name": "Guadeloupe", "code": "GP" }, + { "name": "Guam", "code": "GU" }, + { "name": "Guatemala", "code": "GT" }, + { "name": "Guernsey", "code": "GG" }, + { "name": "Guinea", "code": "GN" }, + { "name": "Guinea-Bissau", "code": "GW" }, + { "name": "Guyana", "code": "GY" }, + { "name": "Haiti", "code": "HT" }, + { "name": "Heard Island and Mcdonald Islands", "code": "HM" }, + { "name": "Holy See (Vatican City State)", "code": "VA" }, + { "name": "Honduras", "code": "HN" }, + { "name": "Hong Kong", "code": "HK" }, + { "name": "Hungary", "code": "HU" }, + { "name": "Iceland", "code": "IS" }, + { "name": "India", "code": "IN" }, + { "name": "Indonesia", "code": "ID" }, + { "name": "Iran, Islamic Republic Of", "code": "IR" }, + { "name": "Iraq", "code": "IQ" }, + { "name": "Ireland", "code": "IE" }, + { "name": "Isle of Man", "code": "IM" }, + { "name": "Israel", "code": "IL" }, + { "name": "Italy", "code": "IT" }, + { "name": "Jamaica", "code": "JM" }, + { "name": "Japan", "code": "JP" }, + { "name": "Jersey", "code": "JE" }, + { "name": "Jordan", "code": "JO" }, + { "name": "Kazakhstan", "code": "KZ" }, + { "name": "Kenya", "code": "KE" }, + { "name": "Kiribati", "code": "KI" }, + { "name": "Korea, Democratic People'S Republic of", "code": "KP" }, + { "name": "Korea, Republic of", "code": "KR" }, + { "name": "Kuwait", "code": "KW" }, + { "name": "Kyrgyzstan", "code": "KG" }, + { "name": "Lao People'S Democratic Republic", "code": "LA" }, + { "name": "Latvia", "code": "LV" }, + { "name": "Lebanon", "code": "LB" }, + { "name": "Lesotho", "code": "LS" }, + { "name": "Liberia", "code": "LR" }, + { "name": "Libyan Arab Jamahiriya", "code": "LY" }, + { "name": "Liechtenstein", "code": "LI" }, + { "name": "Lithuania", "code": "LT" }, + { "name": "Luxembourg", "code": "LU" }, + { "name": "Macao", "code": "MO" }, + { "name": "Macedonia, The Former Yugoslav Republic of", "code": "MK" }, + { "name": "Madagascar", "code": "MG" }, + { "name": "Malawi", "code": "MW" }, + { "name": "Malaysia", "code": "MY" }, + { "name": "Maldives", "code": "MV" }, + { "name": "Mali", "code": "ML" }, + { "name": "Malta", "code": "MT" }, + { "name": "Marshall Islands", "code": "MH" }, + { "name": "Martinique", "code": "MQ" }, + { "name": "Mauritania", "code": "MR" }, + { "name": "Mauritius", "code": "MU" }, + { "name": "Mayotte", "code": "YT" }, + { "name": "Mexico", "code": "MX" }, + { "name": "Micronesia, Federated States of", "code": "FM" }, + { "name": "Moldova, Republic of", "code": "MD" }, + { "name": "Monaco", "code": "MC" }, + { "name": "Mongolia", "code": "MN" }, + { "name": "Montserrat", "code": "MS" }, + { "name": "Morocco", "code": "MA" }, + { "name": "Mozambique", "code": "MZ" }, + { "name": "Myanmar", "code": "MM" }, + { "name": "Namibia", "code": "NA" }, + { "name": "Nauru", "code": "NR" }, + { "name": "Nepal", "code": "NP" }, + { "name": "Netherlands", "code": "NL" }, + { "name": "Netherlands Antilles", "code": "AN" }, + { "name": "New Caledonia", "code": "NC" }, + { "name": "New Zealand", "code": "NZ" }, + { "name": "Nicaragua", "code": "NI" }, + { "name": "Niger", "code": "NE" }, + { "name": "Nigeria", "code": "NG" }, + { "name": "Niue", "code": "NU" }, + { "name": "Norfolk Island", "code": "NF" }, + { "name": "Northern Mariana Islands", "code": "MP" }, + { "name": "Norway", "code": "NO" }, + { "name": "Oman", "code": "OM" }, + { "name": "Pakistan", "code": "PK" }, + { "name": "Palau", "code": "PW" }, + { "name": "Palestinian Territory, Occupied", "code": "PS" }, + { "name": "Panama", "code": "PA" }, + { "name": "Papua New Guinea", "code": "PG" }, + { "name": "Paraguay", "code": "PY" }, + { "name": "Peru", "code": "PE" }, + { "name": "Philippines", "code": "PH" }, + { "name": "Pitcairn", "code": "PN" }, + { "name": "Poland", "code": "PL" }, + { "name": "Portugal", "code": "PT" }, + { "name": "Puerto Rico", "code": "PR" }, + { "name": "Qatar", "code": "QA" }, + { "name": "Reunion", "code": "RE" }, + { "name": "Romania", "code": "RO" }, + { "name": "Russian Federation", "code": "RU" }, + { "name": "RWANDA", "code": "RW" }, + { "name": "Saint Helena", "code": "SH" }, + { "name": "Saint Kitts and Nevis", "code": "KN" }, + { "name": "Saint Lucia", "code": "LC" }, + { "name": "Saint Pierre and Miquelon", "code": "PM" }, + { "name": "Saint Vincent and the Grenadines", "code": "VC" }, + { "name": "Samoa", "code": "WS" }, + { "name": "San Marino", "code": "SM" }, + { "name": "Sao Tome and Principe", "code": "ST" }, + { "name": "Saudi Arabia", "code": "SA" }, + { "name": "Senegal", "code": "SN" }, + { "name": "Serbia", "code": "RS" }, + { "name": "Montenegro", "code": "ME" }, + { "name": "Seychelles", "code": "SC" }, + { "name": "Sierra Leone", "code": "SL" }, + { "name": "Singapore", "code": "SG" }, + { "name": "Slovakia", "code": "SK" }, + { "name": "Slovenia", "code": "SI" }, + { "name": "Solomon Islands", "code": "SB" }, + { "name": "Somalia", "code": "SO" }, + { "name": "South Africa", "code": "ZA" }, + { "name": "South Georgia and the South Sandwich Islands", "code": "GS" }, + { "name": "Spain", "code": "ES" }, + { "name": "Sri Lanka", "code": "LK" }, + { "name": "Sudan", "code": "SD" }, + { "name": "Suriname", "code": "SR" }, + { "name": "Svalbard and Jan Mayen", "code": "SJ" }, + { "name": "Swaziland", "code": "SZ" }, + { "name": "Sweden", "code": "SE" }, + { "name": "Switzerland", "code": "CH" }, + { "name": "Syrian Arab Republic", "code": "SY" }, + { "name": "Taiwan, Province of China", "code": "TW" }, + { "name": "Tajikistan", "code": "TJ" }, + { "name": "Tanzania, United Republic of", "code": "TZ" }, + { "name": "Thailand", "code": "TH" }, + { "name": "Timor-Leste", "code": "TL" }, + { "name": "Togo", "code": "TG" }, + { "name": "Tokelau", "code": "TK" }, + { "name": "Tonga", "code": "TO" }, + { "name": "Trinidad and Tobago", "code": "TT" }, + { "name": "Tunisia", "code": "TN" }, + { "name": "Turkey", "code": "TR" }, + { "name": "Turkmenistan", "code": "TM" }, + { "name": "Turks and Caicos Islands", "code": "TC" }, + { "name": "Tuvalu", "code": "TV" }, + { "name": "Uganda", "code": "UG" }, + { "name": "Ukraine", "code": "UA" }, + { "name": "United Arab Emirates", "code": "AE" }, + { "name": "United Kingdom", "code": "GB" }, + { "name": "United States", "code": "US" }, + { "name": "United States Minor Outlying Islands", "code": "UM" }, + { "name": "Uruguay", "code": "UY" }, + { "name": "Uzbekistan", "code": "UZ" }, + { "name": "Vanuatu", "code": "VU" }, + { "name": "Venezuela", "code": "VE" }, + { "name": "Viet Nam", "code": "VN" }, + { "name": "Virgin Islands, British", "code": "VG" }, + { "name": "Virgin Islands, U.S.", "code": "VI" }, + { "name": "Wallis and Futuna", "code": "WF" }, + { "name": "Western Sahara", "code": "EH" }, + { "name": "Yemen", "code": "YE" }, + { "name": "Zambia", "code": "ZM" }, + { "name": "Zimbabwe", "code": "ZW" } +] diff --git a/frontend/src/json/timezones.json b/frontend/src/json/timezones.json new file mode 100644 index 000000000..62e1fab24 --- /dev/null +++ b/frontend/src/json/timezones.json @@ -0,0 +1,1130 @@ +[ + { + "value": "Dateline Standard Time", + "abbr": "DST", + "offset": -12, + "isdst": false, + "text": "(UTC-12:00) International Date Line West", + "utc": ["Etc/GMT+12"] + }, + { + "value": "UTC-11", + "abbr": "U", + "offset": -11, + "isdst": false, + "text": "(UTC-11:00) Coordinated Universal Time-11", + "utc": ["Etc/GMT+11", "Pacific/Midway", "Pacific/Niue", "Pacific/Pago_Pago"] + }, + { + "value": "Hawaiian Standard Time", + "abbr": "HST", + "offset": -10, + "isdst": false, + "text": "(UTC-10:00) Hawaii", + "utc": ["Etc/GMT+10", "Pacific/Honolulu", "Pacific/Johnston", "Pacific/Rarotonga", "Pacific/Tahiti"] + }, + { + "value": "Alaskan Standard Time", + "abbr": "AKDT", + "offset": -8, + "isdst": true, + "text": "(UTC-09:00) Alaska", + "utc": ["America/Anchorage", "America/Juneau", "America/Nome", "America/Sitka", "America/Yakutat"] + }, + { + "value": "Pacific Standard Time (Mexico)", + "abbr": "PDT", + "offset": -7, + "isdst": true, + "text": "(UTC-08:00) Baja California", + "utc": ["America/Santa_Isabel"] + }, + { + "value": "Pacific Daylight Time", + "abbr": "PDT", + "offset": -7, + "isdst": true, + "text": "(UTC-07:00) Pacific Daylight Time (US & Canada)", + "utc": ["America/Los_Angeles", "America/Tijuana", "America/Vancouver"] + }, + { + "value": "Pacific Standard Time", + "abbr": "PST", + "offset": -8, + "isdst": false, + "text": "(UTC-08:00) Pacific Standard Time (US & Canada)", + "utc": ["America/Los_Angeles", "America/Tijuana", "America/Vancouver", "PST8PDT"] + }, + { + "value": "US Mountain Standard Time", + "abbr": "UMST", + "offset": -7, + "isdst": false, + "text": "(UTC-07:00) Arizona", + "utc": ["America/Creston", "America/Dawson", "America/Dawson_Creek", "America/Hermosillo", "America/Phoenix", "America/Whitehorse", "Etc/GMT+7"] + }, + { + "value": "Mountain Standard Time (Mexico)", + "abbr": "MDT", + "offset": -6, + "isdst": true, + "text": "(UTC-07:00) Chihuahua, La Paz, Mazatlan", + "utc": ["America/Chihuahua", "America/Mazatlan"] + }, + { + "value": "Mountain Standard Time", + "abbr": "MDT", + "offset": -6, + "isdst": true, + "text": "(UTC-07:00) Mountain Time (US & Canada)", + "utc": [ + "America/Boise", + "America/Cambridge_Bay", + "America/Denver", + "America/Edmonton", + "America/Inuvik", + "America/Ojinaga", + "America/Yellowknife", + "MST7MDT" + ] + }, + { + "value": "Central America Standard Time", + "abbr": "CAST", + "offset": -6, + "isdst": false, + "text": "(UTC-06:00) Central America", + "utc": [ + "America/Belize", + "America/Costa_Rica", + "America/El_Salvador", + "America/Guatemala", + "America/Managua", + "America/Tegucigalpa", + "Etc/GMT+6", + "Pacific/Galapagos" + ] + }, + { + "value": "Central Standard Time", + "abbr": "CDT", + "offset": -5, + "isdst": true, + "text": "(UTC-06:00) Central Time (US & Canada)", + "utc": [ + "America/Chicago", + "America/Indiana/Knox", + "America/Indiana/Tell_City", + "America/Matamoros", + "America/Menominee", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Resolute", + "America/Winnipeg", + "CST6CDT" + ] + }, + { + "value": "Central Standard Time (Mexico)", + "abbr": "CDT", + "offset": -5, + "isdst": true, + "text": "(UTC-06:00) Guadalajara, Mexico City, Monterrey", + "utc": ["America/Bahia_Banderas", "America/Cancun", "America/Merida", "America/Mexico_City", "America/Monterrey"] + }, + { + "value": "Canada Central Standard Time", + "abbr": "CCST", + "offset": -6, + "isdst": false, + "text": "(UTC-06:00) Saskatchewan", + "utc": ["America/Regina", "America/Swift_Current"] + }, + { + "value": "SA Pacific Standard Time", + "abbr": "SPST", + "offset": -5, + "isdst": false, + "text": "(UTC-05:00) Bogota, Lima, Quito", + "utc": [ + "America/Bogota", + "America/Cayman", + "America/Coral_Harbour", + "America/Eirunepe", + "America/Guayaquil", + "America/Jamaica", + "America/Lima", + "America/Panama", + "America/Rio_Branco", + "Etc/GMT+5" + ] + }, + { + "value": "Eastern Standard Time", + "abbr": "EST", + "offset": -5, + "isdst": false, + "text": "(UTC-05:00) Eastern Time (US & Canada)", + "utc": [ + "America/Detroit", + "America/Havana", + "America/Indiana/Petersburg", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Iqaluit", + "America/Kentucky/Monticello", + "America/Louisville", + "America/Montreal", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Pangnirtung", + "America/Port-au-Prince", + "America/Thunder_Bay", + "America/Toronto" + ] + }, + { + "value": "Eastern Daylight Time", + "abbr": "EDT", + "offset": -4, + "isdst": true, + "text": "(UTC-04:00) Eastern Daylight Time (US & Canada)", + "utc": [ + "America/Detroit", + "America/Havana", + "America/Indiana/Petersburg", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Iqaluit", + "America/Kentucky/Monticello", + "America/Louisville", + "America/Montreal", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Pangnirtung", + "America/Port-au-Prince", + "America/Thunder_Bay", + "America/Toronto" + ] + }, + { + "value": "US Eastern Standard Time", + "abbr": "UEDT", + "offset": -5, + "isdst": false, + "text": "(UTC-05:00) Indiana (East)", + "utc": ["America/Indiana/Marengo", "America/Indiana/Vevay", "America/Indianapolis"] + }, + { + "value": "Venezuela Standard Time", + "abbr": "VST", + "offset": -4.5, + "isdst": false, + "text": "(UTC-04:30) Caracas", + "utc": ["America/Caracas"] + }, + { + "value": "Paraguay Standard Time", + "abbr": "PYT", + "offset": -4, + "isdst": false, + "text": "(UTC-04:00) Asuncion", + "utc": ["America/Asuncion"] + }, + { + "value": "Atlantic Standard Time", + "abbr": "ADT", + "offset": -3, + "isdst": true, + "text": "(UTC-04:00) Atlantic Time (Canada)", + "utc": ["America/Glace_Bay", "America/Goose_Bay", "America/Halifax", "America/Moncton", "America/Thule", "Atlantic/Bermuda"] + }, + { + "value": "Central Brazilian Standard Time", + "abbr": "CBST", + "offset": -4, + "isdst": false, + "text": "(UTC-04:00) Cuiaba", + "utc": ["America/Campo_Grande", "America/Cuiaba"] + }, + { + "value": "SA Western Standard Time", + "abbr": "SWST", + "offset": -4, + "isdst": false, + "text": "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan", + "utc": [ + "America/Anguilla", + "America/Antigua", + "America/Aruba", + "America/Barbados", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Curacao", + "America/Dominica", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guyana", + "America/Kralendijk", + "America/La_Paz", + "America/Lower_Princes", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Montserrat", + "America/Port_of_Spain", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Santo_Domingo", + "America/St_Barthelemy", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Tortola", + "Etc/GMT+4" + ] + }, + { + "value": "Pacific SA Standard Time", + "abbr": "PSST", + "offset": -4, + "isdst": false, + "text": "(UTC-04:00) Santiago", + "utc": ["America/Santiago", "Antarctica/Palmer"] + }, + { + "value": "Newfoundland Standard Time", + "abbr": "NDT", + "offset": -2.5, + "isdst": true, + "text": "(UTC-03:30) Newfoundland", + "utc": ["America/St_Johns"] + }, + { + "value": "E. South America Standard Time", + "abbr": "ESAST", + "offset": -3, + "isdst": false, + "text": "(UTC-03:00) Brasilia", + "utc": ["America/Sao_Paulo"] + }, + { + "value": "Argentina Standard Time", + "abbr": "AST", + "offset": -3, + "isdst": false, + "text": "(UTC-03:00) Buenos Aires", + "utc": [ + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Buenos_Aires", + "America/Catamarca", + "America/Cordoba", + "America/Jujuy", + "America/Mendoza" + ] + }, + { + "value": "SA Eastern Standard Time", + "abbr": "SEST", + "offset": -3, + "isdst": false, + "text": "(UTC-03:00) Cayenne, Fortaleza", + "utc": [ + "America/Araguaina", + "America/Belem", + "America/Cayenne", + "America/Fortaleza", + "America/Maceio", + "America/Paramaribo", + "America/Recife", + "America/Santarem", + "Antarctica/Rothera", + "Atlantic/Stanley", + "Etc/GMT+3" + ] + }, + { + "value": "Greenland Standard Time", + "abbr": "GDT", + "offset": -3, + "isdst": true, + "text": "(UTC-03:00) Greenland", + "utc": ["America/Godthab"] + }, + { + "value": "Montevideo Standard Time", + "abbr": "MST", + "offset": -3, + "isdst": false, + "text": "(UTC-03:00) Montevideo", + "utc": ["America/Montevideo"] + }, + { + "value": "Bahia Standard Time", + "abbr": "BST", + "offset": -3, + "isdst": false, + "text": "(UTC-03:00) Salvador", + "utc": ["America/Bahia"] + }, + { + "value": "UTC-02", + "abbr": "U", + "offset": -2, + "isdst": false, + "text": "(UTC-02:00) Coordinated Universal Time-02", + "utc": ["America/Noronha", "Atlantic/South_Georgia", "Etc/GMT+2"] + }, + { + "value": "Mid-Atlantic Standard Time", + "abbr": "MDT", + "offset": -1, + "isdst": true, + "text": "(UTC-02:00) Mid-Atlantic - Old", + "utc": [] + }, + { + "value": "Azores Standard Time", + "abbr": "ADT", + "offset": 0, + "isdst": true, + "text": "(UTC-01:00) Azores", + "utc": ["America/Scoresbysund", "Atlantic/Azores"] + }, + { + "value": "Cape Verde Standard Time", + "abbr": "CVST", + "offset": -1, + "isdst": false, + "text": "(UTC-01:00) Cape Verde Is.", + "utc": ["Atlantic/Cape_Verde", "Etc/GMT+1"] + }, + { + "value": "Morocco Standard Time", + "abbr": "MDT", + "offset": 1, + "isdst": true, + "text": "(UTC) Casablanca", + "utc": ["Africa/Casablanca", "Africa/El_Aaiun"] + }, + { + "value": "UTC", + "abbr": "UTC", + "offset": 0, + "isdst": false, + "text": "(UTC) Coordinated Universal Time", + "utc": ["America/Danmarkshavn", "Etc/GMT"] + }, + { + "value": "GMT Standard Time", + "abbr": "GMT", + "offset": 0, + "isdst": false, + "text": "(UTC) Edinburgh, London", + "utc": ["Europe/Isle_of_Man", "Europe/Guernsey", "Europe/Jersey", "Europe/London"] + }, + { + "value": "British Summer Time", + "abbr": "BST", + "offset": 1, + "isdst": true, + "text": "(UTC+01:00) Edinburgh, London", + "utc": ["Europe/Isle_of_Man", "Europe/Guernsey", "Europe/Jersey", "Europe/London"] + }, + { + "value": "GMT Standard Time", + "abbr": "GDT", + "offset": 1, + "isdst": true, + "text": "(UTC) Dublin, Lisbon", + "utc": ["Atlantic/Canary", "Atlantic/Faeroe", "Atlantic/Madeira", "Europe/Dublin", "Europe/Lisbon"] + }, + { + "value": "Greenwich Standard Time", + "abbr": "GST", + "offset": 0, + "isdst": false, + "text": "(UTC) Monrovia, Reykjavik", + "utc": [ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Bamako", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Freetown", + "Africa/Lome", + "Africa/Monrovia", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Sao_Tome", + "Atlantic/Reykjavik", + "Atlantic/St_Helena" + ] + }, + { + "value": "W. Europe Standard Time", + "abbr": "WEDT", + "offset": 2, + "isdst": true, + "text": "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", + "utc": [ + "Arctic/Longyearbyen", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Berlin", + "Europe/Busingen", + "Europe/Gibraltar", + "Europe/Luxembourg", + "Europe/Malta", + "Europe/Monaco", + "Europe/Oslo", + "Europe/Rome", + "Europe/San_Marino", + "Europe/Stockholm", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Zurich" + ] + }, + { + "value": "Central Europe Standard Time", + "abbr": "CEDT", + "offset": 2, + "isdst": true, + "text": "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague", + "utc": ["Europe/Belgrade", "Europe/Bratislava", "Europe/Budapest", "Europe/Ljubljana", "Europe/Podgorica", "Europe/Prague", "Europe/Tirane"] + }, + { + "value": "Romance Standard Time", + "abbr": "RDT", + "offset": 2, + "isdst": true, + "text": "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris", + "utc": ["Africa/Ceuta", "Europe/Brussels", "Europe/Copenhagen", "Europe/Madrid", "Europe/Paris"] + }, + { + "value": "Central European Standard Time", + "abbr": "CEDT", + "offset": 2, + "isdst": true, + "text": "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb", + "utc": ["Europe/Sarajevo", "Europe/Skopje", "Europe/Warsaw", "Europe/Zagreb"] + }, + { + "value": "W. Central Africa Standard Time", + "abbr": "WCAST", + "offset": 1, + "isdst": false, + "text": "(UTC+01:00) West Central Africa", + "utc": [ + "Africa/Algiers", + "Africa/Bangui", + "Africa/Brazzaville", + "Africa/Douala", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Luanda", + "Africa/Malabo", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Porto-Novo", + "Africa/Tunis", + "Etc/GMT-1" + ] + }, + { + "value": "Namibia Standard Time", + "abbr": "NST", + "offset": 1, + "isdst": false, + "text": "(UTC+01:00) Windhoek", + "utc": ["Africa/Windhoek"] + }, + { + "value": "GTB Standard Time", + "abbr": "GDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) Athens, Bucharest", + "utc": ["Asia/Nicosia", "Europe/Athens", "Europe/Bucharest", "Europe/Chisinau"] + }, + { + "value": "Middle East Standard Time", + "abbr": "MEDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) Beirut", + "utc": ["Asia/Beirut"] + }, + { + "value": "Egypt Standard Time", + "abbr": "EST", + "offset": 2, + "isdst": false, + "text": "(UTC+02:00) Cairo", + "utc": ["Africa/Cairo"] + }, + { + "value": "Syria Standard Time", + "abbr": "SDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) Damascus", + "utc": ["Asia/Damascus"] + }, + { + "value": "E. Europe Standard Time", + "abbr": "EEDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) E. Europe", + "utc": [ + "Asia/Nicosia", + "Europe/Athens", + "Europe/Bucharest", + "Europe/Chisinau", + "Europe/Helsinki", + "Europe/Kyiv", + "Europe/Mariehamn", + "Europe/Nicosia", + "Europe/Riga", + "Europe/Sofia", + "Europe/Tallinn", + "Europe/Uzhgorod", + "Europe/Vilnius", + "Europe/Zaporozhye" + ] + }, + { + "value": "South Africa Standard Time", + "abbr": "SAST", + "offset": 2, + "isdst": false, + "text": "(UTC+02:00) Harare, Pretoria", + "utc": [ + "Africa/Blantyre", + "Africa/Bujumbura", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Kigali", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Etc/GMT-2" + ] + }, + { + "value": "FLE Standard Time", + "abbr": "FDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", + "utc": [ + "Europe/Helsinki", + "Europe/Kyiv", + "Europe/Mariehamn", + "Europe/Riga", + "Europe/Sofia", + "Europe/Tallinn", + "Europe/Uzhgorod", + "Europe/Vilnius", + "Europe/Zaporozhye" + ] + }, + { + "value": "Turkey Standard Time", + "abbr": "TDT", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Istanbul", + "utc": ["Europe/Istanbul"] + }, + { + "value": "Israel Standard Time", + "abbr": "JDT", + "offset": 3, + "isdst": true, + "text": "(UTC+02:00) Jerusalem", + "utc": ["Asia/Jerusalem"] + }, + { + "value": "Libya Standard Time", + "abbr": "LST", + "offset": 2, + "isdst": false, + "text": "(UTC+02:00) Tripoli", + "utc": ["Africa/Tripoli"] + }, + { + "value": "Jordan Standard Time", + "abbr": "JST", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Amman", + "utc": ["Asia/Amman"] + }, + { + "value": "Arabic Standard Time", + "abbr": "AST", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Baghdad", + "utc": ["Asia/Baghdad"] + }, + { + "value": "Kaliningrad Standard Time", + "abbr": "KST", + "offset": 3, + "isdst": false, + "text": "(UTC+02:00) Kaliningrad", + "utc": ["Europe/Kaliningrad"] + }, + { + "value": "Arab Standard Time", + "abbr": "AST", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Kuwait, Riyadh", + "utc": ["Asia/Aden", "Asia/Bahrain", "Asia/Kuwait", "Asia/Qatar", "Asia/Riyadh"] + }, + { + "value": "E. Africa Standard Time", + "abbr": "EAST", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Nairobi", + "utc": [ + "Africa/Addis_Ababa", + "Africa/Asmera", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Mogadishu", + "Africa/Nairobi", + "Antarctica/Syowa", + "Etc/GMT-3", + "Indian/Antananarivo", + "Indian/Comoro", + "Indian/Mayotte" + ] + }, + { + "value": "Moscow Standard Time", + "abbr": "MSK", + "offset": 3, + "isdst": false, + "text": "(UTC+03:00) Moscow, St. Petersburg, Volgograd, Minsk", + "utc": ["Europe/Kirov", "Europe/Moscow", "Europe/Simferopol", "Europe/Volgograd", "Europe/Minsk"] + }, + { + "value": "Samara Time", + "abbr": "SAMT", + "offset": 4, + "isdst": false, + "text": "(UTC+04:00) Samara, Ulyanovsk, Saratov", + "utc": ["Europe/Astrakhan", "Europe/Samara", "Europe/Ulyanovsk"] + }, + { + "value": "Iran Standard Time", + "abbr": "IDT", + "offset": 4.5, + "isdst": true, + "text": "(UTC+03:30) Tehran", + "utc": ["Asia/Tehran"] + }, + { + "value": "Arabian Standard Time", + "abbr": "AST", + "offset": 4, + "isdst": false, + "text": "(UTC+04:00) Abu Dhabi, Muscat", + "utc": ["Asia/Dubai", "Asia/Muscat", "Etc/GMT-4"] + }, + { + "value": "Azerbaijan Standard Time", + "abbr": "ADT", + "offset": 5, + "isdst": true, + "text": "(UTC+04:00) Baku", + "utc": ["Asia/Baku"] + }, + { + "value": "Mauritius Standard Time", + "abbr": "MST", + "offset": 4, + "isdst": false, + "text": "(UTC+04:00) Port Louis", + "utc": ["Indian/Mahe", "Indian/Mauritius", "Indian/Reunion"] + }, + { + "value": "Georgian Standard Time", + "abbr": "GET", + "offset": 4, + "isdst": false, + "text": "(UTC+04:00) Tbilisi", + "utc": ["Asia/Tbilisi"] + }, + { + "value": "Caucasus Standard Time", + "abbr": "CST", + "offset": 4, + "isdst": false, + "text": "(UTC+04:00) Yerevan", + "utc": ["Asia/Yerevan"] + }, + { + "value": "Afghanistan Standard Time", + "abbr": "AST", + "offset": 4.5, + "isdst": false, + "text": "(UTC+04:30) Kabul", + "utc": ["Asia/Kabul"] + }, + { + "value": "West Asia Standard Time", + "abbr": "WAST", + "offset": 5, + "isdst": false, + "text": "(UTC+05:00) Ashgabat, Tashkent", + "utc": [ + "Antarctica/Mawson", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Dushanbe", + "Asia/Oral", + "Asia/Samarkand", + "Asia/Tashkent", + "Etc/GMT-5", + "Indian/Kerguelen", + "Indian/Maldives" + ] + }, + { + "value": "Yekaterinburg Time", + "abbr": "YEKT", + "offset": 5, + "isdst": false, + "text": "(UTC+05:00) Yekaterinburg", + "utc": ["Asia/Yekaterinburg"] + }, + { + "value": "Pakistan Standard Time", + "abbr": "PKT", + "offset": 5, + "isdst": false, + "text": "(UTC+05:00) Islamabad, Karachi", + "utc": ["Asia/Karachi"] + }, + { + "value": "India Standard Time", + "abbr": "IST", + "offset": 5.5, + "isdst": false, + "text": "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi", + "utc": ["Asia/Kolkata", "Asia/Calcutta"] + }, + { + "value": "Sri Lanka Standard Time", + "abbr": "SLST", + "offset": 5.5, + "isdst": false, + "text": "(UTC+05:30) Sri Jayawardenepura", + "utc": ["Asia/Colombo"] + }, + { + "value": "Nepal Standard Time", + "abbr": "NST", + "offset": 5.75, + "isdst": false, + "text": "(UTC+05:45) Kathmandu", + "utc": ["Asia/Kathmandu"] + }, + { + "value": "Central Asia Standard Time", + "abbr": "CAST", + "offset": 6, + "isdst": false, + "text": "(UTC+06:00) Nur-Sultan (Astana)", + "utc": ["Antarctica/Vostok", "Asia/Almaty", "Asia/Bishkek", "Asia/Qyzylorda", "Asia/Urumqi", "Etc/GMT-6", "Indian/Chagos"] + }, + { + "value": "Bangladesh Standard Time", + "abbr": "BST", + "offset": 6, + "isdst": false, + "text": "(UTC+06:00) Dhaka", + "utc": ["Asia/Dhaka", "Asia/Thimphu"] + }, + { + "value": "Myanmar Standard Time", + "abbr": "MST", + "offset": 6.5, + "isdst": false, + "text": "(UTC+06:30) Yangon (Rangoon)", + "utc": ["Asia/Rangoon", "Indian/Cocos"] + }, + { + "value": "SE Asia Standard Time", + "abbr": "SAST", + "offset": 7, + "isdst": false, + "text": "(UTC+07:00) Bangkok, Hanoi, Jakarta", + "utc": [ + "Antarctica/Davis", + "Asia/Bangkok", + "Asia/Hovd", + "Asia/Jakarta", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Saigon", + "Asia/Vientiane", + "Etc/GMT-7", + "Indian/Christmas" + ] + }, + { + "value": "N. Central Asia Standard Time", + "abbr": "NCAST", + "offset": 7, + "isdst": false, + "text": "(UTC+07:00) Novosibirsk", + "utc": ["Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk"] + }, + { + "value": "China Standard Time", + "abbr": "CST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi", + "utc": ["Asia/Hong_Kong", "Asia/Macau", "Asia/Shanghai"] + }, + { + "value": "North Asia Standard Time", + "abbr": "NAST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Krasnoyarsk", + "utc": ["Asia/Krasnoyarsk"] + }, + { + "value": "Singapore Standard Time", + "abbr": "MPST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Kuala Lumpur, Singapore", + "utc": ["Asia/Brunei", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Makassar", "Asia/Manila", "Asia/Singapore", "Etc/GMT-8"] + }, + { + "value": "W. Australia Standard Time", + "abbr": "WAST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Perth", + "utc": ["Antarctica/Casey", "Australia/Perth"] + }, + { + "value": "Taipei Standard Time", + "abbr": "TST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Taipei", + "utc": ["Asia/Taipei"] + }, + { + "value": "Ulaanbaatar Standard Time", + "abbr": "UST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Ulaanbaatar", + "utc": ["Asia/Choibalsan", "Asia/Ulaanbaatar"] + }, + { + "value": "North Asia East Standard Time", + "abbr": "NAEST", + "offset": 8, + "isdst": false, + "text": "(UTC+08:00) Irkutsk", + "utc": ["Asia/Irkutsk"] + }, + { + "value": "Japan Standard Time", + "abbr": "JST", + "offset": 9, + "isdst": false, + "text": "(UTC+09:00) Osaka, Sapporo, Tokyo", + "utc": ["Asia/Dili", "Asia/Jayapura", "Asia/Tokyo", "Etc/GMT-9", "Pacific/Palau"] + }, + { + "value": "Korea Standard Time", + "abbr": "KST", + "offset": 9, + "isdst": false, + "text": "(UTC+09:00) Seoul", + "utc": ["Asia/Pyongyang", "Asia/Seoul"] + }, + { + "value": "Cen. Australia Standard Time", + "abbr": "CAST", + "offset": 9.5, + "isdst": false, + "text": "(UTC+09:30) Adelaide", + "utc": ["Australia/Adelaide", "Australia/Broken_Hill"] + }, + { + "value": "AUS Central Standard Time", + "abbr": "ACST", + "offset": 9.5, + "isdst": false, + "text": "(UTC+09:30) Darwin", + "utc": ["Australia/Darwin"] + }, + { + "value": "E. Australia Standard Time", + "abbr": "EAST", + "offset": 10, + "isdst": false, + "text": "(UTC+10:00) Brisbane", + "utc": ["Australia/Brisbane", "Australia/Lindeman"] + }, + { + "value": "AUS Eastern Standard Time", + "abbr": "AEST", + "offset": 10, + "isdst": false, + "text": "(UTC+10:00) Canberra, Melbourne, Sydney", + "utc": ["Australia/Melbourne", "Australia/Sydney"] + }, + { + "value": "West Pacific Standard Time", + "abbr": "WPST", + "offset": 10, + "isdst": false, + "text": "(UTC+10:00) Guam, Port Moresby", + "utc": ["Antarctica/DumontDUrville", "Etc/GMT-10", "Pacific/Guam", "Pacific/Port_Moresby", "Pacific/Saipan", "Pacific/Truk"] + }, + { + "value": "Tasmania Standard Time", + "abbr": "TST", + "offset": 10, + "isdst": false, + "text": "(UTC+10:00) Hobart", + "utc": ["Australia/Currie", "Australia/Hobart"] + }, + { + "value": "Yakutsk Standard Time", + "abbr": "YST", + "offset": 9, + "isdst": false, + "text": "(UTC+09:00) Yakutsk", + "utc": ["Asia/Chita", "Asia/Khandyga", "Asia/Yakutsk"] + }, + { + "value": "Central Pacific Standard Time", + "abbr": "CPST", + "offset": 11, + "isdst": false, + "text": "(UTC+11:00) Solomon Is., New Caledonia", + "utc": ["Antarctica/Macquarie", "Etc/GMT-11", "Pacific/Efate", "Pacific/Guadalcanal", "Pacific/Kosrae", "Pacific/Noumea", "Pacific/Ponape"] + }, + { + "value": "Vladivostok Standard Time", + "abbr": "VST", + "offset": 11, + "isdst": false, + "text": "(UTC+11:00) Vladivostok", + "utc": ["Asia/Sakhalin", "Asia/Ust-Nera", "Asia/Vladivostok"] + }, + { + "value": "New Zealand Standard Time", + "abbr": "NZST", + "offset": 12, + "isdst": false, + "text": "(UTC+12:00) Auckland, Wellington", + "utc": ["Antarctica/McMurdo", "Pacific/Auckland"] + }, + { + "value": "UTC+12", + "abbr": "U", + "offset": 12, + "isdst": false, + "text": "(UTC+12:00) Coordinated Universal Time+12", + "utc": [ + "Etc/GMT-12", + "Pacific/Funafuti", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Nauru", + "Pacific/Tarawa", + "Pacific/Wake", + "Pacific/Wallis" + ] + }, + { + "value": "Fiji Standard Time", + "abbr": "FST", + "offset": 12, + "isdst": false, + "text": "(UTC+12:00) Fiji", + "utc": ["Pacific/Fiji"] + }, + { + "value": "Magadan Standard Time", + "abbr": "MST", + "offset": 12, + "isdst": false, + "text": "(UTC+12:00) Magadan", + "utc": ["Asia/Anadyr", "Asia/Kamchatka", "Asia/Magadan", "Asia/Srednekolymsk"] + }, + { + "value": "Kamchatka Standard Time", + "abbr": "KDT", + "offset": 13, + "isdst": true, + "text": "(UTC+12:00) Petropavlovsk-Kamchatsky - Old", + "utc": ["Asia/Kamchatka"] + }, + { + "value": "Tonga Standard Time", + "abbr": "TST", + "offset": 13, + "isdst": false, + "text": "(UTC+13:00) Nuku'alofa", + "utc": ["Etc/GMT-13", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Tongatapu"] + }, + { + "value": "Samoa Standard Time", + "abbr": "SST", + "offset": 13, + "isdst": false, + "text": "(UTC+13:00) Samoa", + "utc": ["Pacific/Apia"] + } +] diff --git a/frontend/src/lib/ascii.ts b/frontend/src/lib/ascii.ts new file mode 100644 index 000000000..5771ca7a8 --- /dev/null +++ b/frontend/src/lib/ascii.ts @@ -0,0 +1,5 @@ +export const renderAscii = () => { + console.info( + ' _ _ \n ▒▓█████▓▒ ___ ___| | | __ _ \n ▒▓█ █▓▒ / __/ _ \\ | |/ _` | \n ▒▓█ █▓▒ | (_| __/ | | (_| | \n ▒▓█████▓▒ \\___\\___|_|_|\\__,_| \n ', + ); +}; diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts new file mode 100644 index 000000000..3f439b581 --- /dev/null +++ b/frontend/src/lib/export.ts @@ -0,0 +1,114 @@ +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import type { ReactElement } from 'react'; +import type { Theme } from '~/store/theme'; + +dayjs.extend(localizedFormat); + +interface Column { + key: string; + name: ReactElement | string; +} + +const formatBodyData = (rows: R[], columns: Column[]): (string | number)[][] => { + const formatRowData = (row: R, column: Column) => { + if (column.key === 'adminCount' || column.key === 'memberCount') { + const key = column.key.replace('Count', 's'); + return ( + row as { + counts: { + memberships: Record; + }; + } + ).counts.memberships[key]; + } + const date = dayjs((row as Record)[column.key]); + if (date.isValid()) { + return date.format('lll'); + } + return (row as Record)[column.key]; + }; + + return rows.map((row) => columns.map((column) => formatRowData(row, column))); +}; + +const filterColumns = (column: Column) => { + if ('visible' in column && column.key !== 'checkbox-column') return column.visible as boolean; + return false; +}; +// Export table data to CSV +export async function exportToCsv(columns: { key: string; name: ReactElement | string }[], rows: R[], fileName: string) { + if (!rows.length) return; + + const preparedColumns = columns.filter((column) => filterColumns(column)); + const head = [preparedColumns.map((column) => String(column.name))]; + const body = formatBodyData(rows, preparedColumns); + const content = [...head, ...body].map((cells) => cells.map(serialiseCellValue).join(',')).join('\n'); + + downloadFile(fileName, new Blob([content], { type: 'text/csv;charset=utf-8;' })); +} + +export async function exportToPdf( + columns: { key: string; name: ReactElement | string }[], + rows: R[], + fileName: string, + pageName: string, + theme: Theme, +) { + // Redo type assign into the columns + const preparedColumns = columns.filter((column) => filterColumns(column)); + const head = [preparedColumns.map((column) => String(column.name))]; + const body = formatBodyData(rows, preparedColumns); + + const [{ jsPDF }, autoTable] = await Promise.all([import('jspdf'), (await import('jspdf-autotable')).default]); + const doc = new jsPDF({ + orientation: 'l', + unit: 'px', + }); + + // Add date of export and name of the page that is exported. + const exportDate = dayjs().format('lll'); + const exportInfo = `Exported from page: ${pageName}\nExport Date: ${exportDate}`; + doc.text(exportInfo, 10, 10); + + const textColor = theme === 'dark' ? '#f2f2f2' : '#17171C'; + const backgroundColor = theme === 'dark' ? '#151519' : '#ffffff'; + const alternateBackgroundColor = theme === 'dark' ? '#2c2c2f' : '#e5e5e5'; + + autoTable(doc, { + head, + body, + startY: 40, + horizontalPageBreak: true, + styles: { + cellPadding: 1.5, + fontSize: 10, + cellWidth: 'wrap', + textColor, + fillColor: backgroundColor, + }, + bodyStyles: { + fillColor: backgroundColor, + }, + alternateRowStyles: { fillColor: alternateBackgroundColor }, + tableWidth: 'wrap', + }); + doc.save(fileName); +} + +function serialiseCellValue(value: unknown) { + if (typeof value === 'string') { + const formattedValue = value.replace(/"/g, '""'); + return formattedValue.includes(',') ? `"${formattedValue}"` : formattedValue; + } + return value; +} + +function downloadFile(fileName: string, data: Blob) { + const downloadLink = document.createElement('a'); + downloadLink.download = fileName; + const url = URL.createObjectURL(data); + downloadLink.href = url; + downloadLink.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/health-check.ts b/frontend/src/lib/health-check.ts new file mode 100644 index 000000000..cf796ddfa --- /dev/null +++ b/frontend/src/lib/health-check.ts @@ -0,0 +1,35 @@ +// Health check utility to check if backend is up and running again +export const healthCheck = async ( + url: string, + maxDelay = 600, // Maximum 10 minutes + factor = 1.5, + maxAttempts = 10, +): Promise => { + let delay = 10000; // Initial delay 10 second + let attempts = 0; + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + while (attempts < maxAttempts) { + attempts++; + try { + console.debug(`Attempt ${attempts}: Pinging ${url}`); + const response = await fetch(url); + if (response.ok) { + console.debug('Health check successful!'); + return true; + } + console.debug(`Health check: ${response.status}`); + } catch (err) {} + + if (delay < maxDelay * 1000) { + delay = Math.min(maxDelay * 1000, delay * factor); + } + + console.debug(`Waiting ${delay / 1000} seconds before next attempt.`); + await sleep(delay); + } + + console.debug('Maximum attempts reached. Health check failed.'); + return false; +}; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 000000000..e60807aba --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,36 @@ +import i18n, { type InitOptions } from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import Backend from 'i18next-http-backend'; +import { initReactI18next } from 'react-i18next'; +import about from '../../../locales/en/about.json'; +import common from '../../../locales/en/common.json'; + +import { config } from 'config'; + +export type { ParseKeys } from 'i18next'; + +// Set up i18n with hybrid preload and lazy loading strategy +const initOptions: InitOptions = { + resources: { en: { common, about } }, // Preload default ('en') translations + debug: config.debug, + ns: ['common', 'about'], + partialBundledLanguages: true, + supportedLngs: config.languages.map((lng) => lng.value), + load: 'languageOnly', + fallbackLng: config.defaultLanguage, + interpolation: { + escapeValue: false, // React escapes by default + }, + react: { + useSuspense: false, + }, + defaultNS: 'common', + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, +}; + +// Init i18n instance +i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init(initOptions); + +export { i18n }; diff --git a/frontend/src/lib/imado.ts b/frontend/src/lib/imado.ts new file mode 100644 index 000000000..62ca60c83 --- /dev/null +++ b/frontend/src/lib/imado.ts @@ -0,0 +1,66 @@ +import { type UploadResult, Uppy, type UppyOptions } from '@uppy/core'; + +import Tus from '@uppy/tus'; +import { config } from 'config'; +import { getUploadToken } from '../api/general'; +import type { UploadParams, UploadType } from '../types'; + +import '@uppy/core/dist/style.min.css'; + +const readJwt = (token: string) => JSON.parse(atob(token.split('.')[1])); + +interface ImadoUploadParams extends UploadParams { + completionHandler: (urls: URL[], result?: UploadResult) => void; +} +// ImadoUppy helps to create an Uppy instance that works with the Imado API +export async function ImadoUppy( + type: UploadType, + uppyOptions: UppyOptions, + opts: ImadoUploadParams = { public: false, completionHandler: () => {}, organizationId: undefined }, +): Promise { + // Get upload token and check if public or private files + const token = await getUploadToken(type, { public: opts.public, organizationId: opts.organizationId }); + + if (!token) throw new Error('Failed to get upload token'); + + const { public: isPublic, sub, imado: useImadoAPI } = readJwt(token); + + const rootUrl = isPublic ? config.publicCDNUrl : config.privateCDNUrl; + + // Create Uppy instance + const imadoUppy = new Uppy({ + ...uppyOptions, + meta: { + public: isPublic, + }, + }) + .use(Tus, { + endpoint: config.tusUrl, + removeFingerprintOnSuccess: true, + headers: { + authorization: `Bearer ${token}`, + }, + }) + .on('upload', (data) => { + console.info('Upload started:', data); + }) + .on('error', (error) => { + console.error('Upload error:', error); + }) + .on('complete', (result: UploadResult) => { + if (!useImadoAPI) console.warn('Imado API is disabled, files will not be uploaded to Imado.'); + + if (result.successful && useImadoAPI) { + const urls = result.successful.map((file) => { + const uploadKey = file.uploadURL.split('/').pop(); + // Sub can be user id, or user id w/ organization id + return new URL(`${rootUrl}/${sub}/${uploadKey}`); + }); + opts.completionHandler(urls, result); + } else { + opts.completionHandler([], result); + } + }); + + return imadoUppy; +} diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts new file mode 100644 index 000000000..7d9930af6 --- /dev/null +++ b/frontend/src/lib/query-client.ts @@ -0,0 +1,62 @@ +import i18next from 'i18next'; +import { toast } from 'sonner'; +import { ApiError } from '~/api'; +import { i18n } from '~/lib/i18n'; +import { useAlertStore } from '~/store/alert'; +import { useUserStore } from '~/store/user'; +import type { MeUser } from '~/types'; +import router from './router'; + +// Fallback messages for common errors +const fallbackMessages = (t: (typeof i18n)['t']) => ({ + '400': t('common:error.bad_request_action'), + '401': t('common:error.unauthorized_action'), + '403': t('common:error.forbidden_action'), + '404': t('common:error.not_found'), + '429': t('common:error.too_many_requests'), +}); + +export const onError = (error: Error) => { + if (error instanceof ApiError) { + const statusCode = Number(error.status); + // Abort if /me or /menu, it should fail silently + if (error.path && ['/me', '/menu'].includes(error.path)) return; + + const fallback = fallbackMessages(i18n.t); + + // Translate, try most specific first + const errorMessage = + error.entityType && i18next.exists(`common:error.resource_${error.type}`) + ? i18n.t(`error.resource_${error.type}`, { resource: i18n.t(error.entityType.toLowerCase()) }) + : error.type && i18next.exists(`common:error.${error.type}`) + ? i18n.t(`common:error.${error.type}`) + : fallback[error.status as keyof typeof fallback]; + + // Show toast + toast.error(errorMessage || error.message); + + // Set down alerts + if ([503, 502].includes(statusCode)) useAlertStore.getState().setDownAlert('maintenance'); + else if (statusCode === 504) useAlertStore.getState().setDownAlert('offline'); + + if (statusCode === 401) { + // Redirect to sign-in page if the user is not authenticated (except for /me) + const redirectOptions: { to: string; replace: boolean; search?: { redirect: string } } = { to: '/auth/sign-in', replace: true }; + + // If the path is not /auth/*, save the current path as a redirect + if (location.pathname?.length > 2 && !location.pathname.startsWith('/auth/')) { + redirectOptions.search = { redirect: location.pathname }; + } + + useUserStore.setState({ user: null as unknown as MeUser }); + router.navigate(redirectOptions); + } + } +}; + +const onSuccess = () => { + // Clear down alerts + useAlertStore.getState().setDownAlert(null); +}; + +export const queryClientConfig = { onError, onSuccess }; diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts new file mode 100644 index 000000000..59f7affee --- /dev/null +++ b/frontend/src/lib/router.ts @@ -0,0 +1,35 @@ +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from '~/routes/routeTree'; +import { queryClientConfig } from './query-client'; + +// Set up a QueryClient instance +// https://tanstack.com/query/latest/docs/reference/QueryClient +export const queryClient = new QueryClient({ + mutationCache: new MutationCache(queryClientConfig), + queryCache: new QueryCache(queryClientConfig), +}); + +// Set up a Router instance +// https://tanstack.com/router/latest/docs/framework/react/api/router/createRouterFunction +const router = createRouter({ + routeTree, + // notFoundRoute, + defaultPreload: false, + context: { queryClient }, +}); + +// Register the router +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } + + // Required pageTitle in static data + interface StaticDataRouteOption { + pageTitle: string | null; + hideFooter?: boolean; + } +} + +export default router; diff --git a/frontend/src/lib/sentry.ts b/frontend/src/lib/sentry.ts new file mode 100644 index 000000000..aa6595f95 --- /dev/null +++ b/frontend/src/lib/sentry.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/react'; +import { config } from 'config'; + +export const initSentry = () => { + // Send errors to Sentry + Sentry.init({ + dsn: config.sentryDsn, + enabled: config.mode === 'production', + environment: config.mode, + // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. + tracesSampleRate: 1.0, + // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled + tracePropagationTargets: ['localhost', config.backendUrl, config.frontendUrl, config.tusUrl], + // Capture Replay for 10% of all sessions, plus for 100% of sessions with an error + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + }); +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..84aff79cc --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,162 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/types'; +import { redirect } from '@tanstack/react-router'; +import { type ClassValue, clsx } from 'clsx'; +import dayjs from 'dayjs'; +// @ts-ignore +import calendar from 'dayjs/plugin/calendar'; +// import relativeTime from 'dayjs/plugin/relativeTime'; +import i18next from 'i18next'; +import { customAlphabet } from 'nanoid'; +import * as React from 'react'; +import { flushSync } from 'react-dom'; +import { twMerge } from 'tailwind-merge'; +import type { Task } from '~/modules/common/root/electric'; +import type { DraggableItemData } from '~/types'; + +// TODO: SSRs do not work and I want to make sure they work at client time +// dayjs.extend(calendar); +// dayjs.extend(relativeTime); + +// Format a date to a relative time +export function dateShort(date?: string | null | Date) { + if (!date) return '-'; + + return dayjs(date).calendar(null, { + sameDay: '[Today], H:mm', + lastDay: '[Yesterday], H:mm', + lastWeek: 'dddd, H:mm', + sameElse: (now: dayjs.Dayjs) => { + const monthDiff = now.diff(dayjs(date), 'month'); + if (monthDiff <= 3) return dayjs(date).format('MMM D, H:mm'); + return dayjs(date).format('MMM D, YYYY'); + }, + }); +} + +// nanoid with only lowercase letters and numbers +export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789'); + +// Merge tailwind classes +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// Start a View Transition +export function makeTransition(transition: () => void) { + // @ts-ignore + if (document.startViewTransition) { + // @ts-ignore + document.startViewTransition(() => { + flushSync(() => { + transition(); + }); + }); + } else { + transition(); + } +} + +const colors = [ + 'bg-blue-300', + 'bg-lime-300', + 'bg-orange-300', + 'bg-yellow-300', + 'bg-green-300', + 'bg-teal-300', + 'bg-indigo-300', + 'bg-purple-300', + 'bg-pink-300', + 'bg-red-300', +]; + +// Get a color class based on an id +export const getColorClass = (id?: string) => { + if (!id) return 'bg-gray-300'; + + const index = generateNumber(id) || 0; + return colors[index]; +}; + +// Generate a number from a string (ie. to choose a color) +export function generateNumber(id: string) { + if (!id) return null; + + for (let i = id.length - 1; i >= 0; i--) { + const char = id[i].toLowerCase(); + if (Number.parseInt(char) >= 0 && Number.parseInt(char) <= 9) { + return Number.parseInt(char) % 10; + } + if (char >= 'a' && char <= 'z') { + return (char.charCodeAt(0) - 'a'.charCodeAt(0)) % 10; + } + } + return null; +} + +// Get valid children from a React component +export function getValidChildren(children: React.ReactNode) { + return React.Children.toArray(children).filter((child) => React.isValidElement(child)) as React.ReactElement[]; +} + +// Clean a URL by removing search and hash +export function cleanUrl(url?: string | null) { + if (!url) return null; + + const newUrl = new URL(url); + newUrl.search = ''; + newUrl.hash = ''; + return newUrl.toString(); +} + +// If key and value are equal, then translation does not exist +export const translationExists = (key: string) => { + return i18next.t(key) !== key; +}; + +// Prevent direct access to a parent route, always redirect to a child +export const noDirectAccess = (pathname: string, param: string, redirectLocation: string) => { + if (!pathname.endsWith(param)) return; + throw redirect({ to: pathname + redirectLocation, replace: true }); +}; + +// To sort Tasks by its status & order +export const sortTaskOrder = (task1: Task, task2: Task) => { + if (task1.status !== task2.status) return task2.status - task1.status; + // same status, sort by sort_order + if (task1.sort_order !== null && task2.sort_order !== null) return task2.sort_order - task1.sort_order; + // sort_order is null + return 0; +}; + +export const arrayMove = (array: string[], startIndex: number, endIndex: number) => { + const newArray = [...array]; + const [removedElement] = newArray.splice(startIndex, 1); + newArray.splice(endIndex, 0, removedElement); + return newArray; +}; + +export const getDraggableItemData = (item: T, itemIndex: number, type: 'task' | 'column' | 'menuItem'): DraggableItemData => { + return { dragItem: true, item, index: itemIndex, type }; +}; + +// To get target index for drop on DnD +export const getReorderDestinationIndex = ( + currentIndex: number, + closestEdgeOfTarget: Edge | null, + targetIndex: number, + axis: 'vertical' | 'horizontal', +): number => { + // if (axis === 'horizontal') { + // if (closestEdgeOfTarget === 'left') { + // return indexOfTarget; + // } else if (closestEdgeOfTarget === 'right') { + // return indexOfTarget + 1; + // } + // } else + if (axis === 'vertical') { + if (closestEdgeOfTarget === 'top') return targetIndex - 1; + + if (closestEdgeOfTarget === 'bottom') return targetIndex; + } + return currentIndex; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..1cf649911 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,32 @@ +import './index.css'; + +import { StrictMode } from 'react'; +import ReactDOM from 'react-dom/client'; +import { ThemeManager } from '~/modules/common/theme-manager'; + +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from '@tanstack/react-router'; +import './lib/i18n'; + +const root = document.getElementById('root'); +if (!root) throw new Error('Root element not found'); + +import { renderAscii } from '~/lib/ascii'; +import { queryClient } from '~/lib/router'; +import router from '~/lib/router'; +import { initSentry } from '~/lib/sentry'; + +// Render ASCII logo in console +renderAscii(); + +// Initialize Sentry +initSentry(); + +ReactDOM.createRoot(root).render( + + + + + + , +); diff --git a/frontend/src/modules/auth/auth-page.tsx b/frontend/src/modules/auth/auth-page.tsx new file mode 100644 index 000000000..833eecbb3 --- /dev/null +++ b/frontend/src/modules/auth/auth-page.tsx @@ -0,0 +1,46 @@ +import { Link } from '@tanstack/react-router'; +import { Suspense, lazy } from 'react'; +import useMounted from '~/hooks/use-mounted'; +import { cn } from '~/lib/utils'; +import { type FooterLinkProps, FooterLinks } from '~/modules/common/app-footer'; +import Logo from '~/modules/common/logo'; + +interface AuthPageProps { + children?: React.ReactNode; +} + +// Auth footer links +const authFooterLinks: FooterLinkProps[] = [{ id: 'about', href: '/about' }]; + +// Lazy load bg animation +const BgAnimation = lazy(() => import('~/modules/common/bg-animation')); + +const AuthPage = ({ children }: AuthPageProps) => { + const { hasStarted, hasWaited } = useMounted(); + const animateClass = `transition-all will-change-transform duration-500 ease-out ${hasStarted ? 'opacity-1' : 'opacity-0 scale-95 translate-y-4'}`; + + return ( +
+ {/* Render bg animation */} + +
+ +
+
+ +
+
+ {children} + + + + + + +
+
+
+ ); +}; + +export default AuthPage; diff --git a/frontend/src/modules/auth/check-email-form.tsx b/frontend/src/modules/auth/check-email-form.tsx new file mode 100644 index 000000000..408875677 --- /dev/null +++ b/frontend/src/modules/auth/check-email-form.tsx @@ -0,0 +1,92 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { emailBodySchema } from 'backend/modules/auth/schema'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type * as z from 'zod'; + +import { Button } from '~/modules/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/form'; +import { Input } from '~/modules/ui/input'; + +import { config } from 'config'; +import { ArrowRight } from 'lucide-react'; +import { useEffect } from 'react'; +import { checkEmail as baseCheckEmail } from '~/api/auth'; +import { useMutation } from '~/hooks/use-mutations'; +import type { TokenData } from '.'; + +const formSchema = emailBodySchema; + +interface CheckEmailProps { + tokenData: TokenData | null; + setStep: (step: string, email: string) => void; +} + +export const CheckEmailForm = ({ tokenData, setStep }: CheckEmailProps) => { + const { t } = useTranslation(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { email: '' }, + }); + + const { mutate: checkEmail, isPending } = useMutation({ + mutationFn: baseCheckEmail, + onSuccess: (success) => { + // Depending on config, we have different steps + let nextStep = success ? 'signIn' : 'inviteOnly'; + if (config.has.signUp) { + nextStep = success ? 'signIn' : 'signUp'; + } else if (config.has.waitList) { + nextStep = success ? 'signIn' : 'waitList'; + } + + setStep(nextStep, form.getValues('email')); + }, + }); + + const onSubmit = (values: z.infer) => { + checkEmail(values.email); + }; + + const title = config.has.signUp + ? tokenData + ? t('common:invite_sign_in_or_up') + : t('common:sign_in_or_up') + : tokenData + ? t('common:invite_sign_in') + : t('common:sign_in'); + + useEffect(() => { + if (tokenData?.email) { + form.setValue('email', tokenData.email); + form.handleSubmit(onSubmit)(); + } + }, [tokenData]); + + return ( +
+

{title}

+ + + ( + // Custom css due to html injection by browser extensions + + + + + + + )} + /> + + + + ); +}; diff --git a/frontend/src/modules/auth/index.tsx b/frontend/src/modules/auth/index.tsx new file mode 100644 index 000000000..1c0d60851 --- /dev/null +++ b/frontend/src/modules/auth/index.tsx @@ -0,0 +1,94 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; +import { CheckEmailForm } from './check-email-form'; +import { SignInForm } from './sign-in-form'; +import { SignUpForm } from './sign-up-form'; + +import { Link, useSearch } from '@tanstack/react-router'; +import { config } from 'config'; +import { ArrowRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ApiError } from '~/api'; +import { checkToken } from '~/api/general'; +import { cn } from '~/lib/utils'; +import { SignInRoute } from '~/routes/authentication'; +import { useUserStore } from '~/store/user'; +import { WaitListForm } from '../common/wait-list-form'; +import { buttonVariants } from '../ui/button'; +import AuthPage from './auth-page'; +import OauthOptions from './oauth-options'; + +export type Step = 'check' | 'signIn' | 'signUp' | 'inviteOnly' | 'error' | 'waitList'; + +export type TokenData = Awaited> & { + token: string; +}; + +const SignIn = () => { + const { t } = useTranslation(); + const { lastUser } = useUserStore(); + const [step, setStep] = useState('check'); + const [email, setEmail] = useState(''); + const [tokenData, setTokenData] = useState(null); + const [error, setError] = useState(null); + + const { token } = useSearch({ + from: SignInRoute.id, + }); + + useLayoutEffect(() => { + if (token) { + checkToken(token) + .then((data) => { + setTokenData({ + ...data, + token, + }); + setEmail(data.email); + }) + .catch(setError); + } + }, [token]); + + useEffect(() => { + if (lastUser?.email && !token) handleCheckEmail('signIn', lastUser.email); + }, [lastUser]); + + const handleCheckEmail = (step: string, email: string) => { + setEmail(email); + setStep(step as Step); + }; + + const handleSetStep = (step: string) => { + setStep(step as Step); + }; + + return ( + + {!error ? ( + <> + {step === 'check' && } + {step === 'signIn' && } + {step === 'signUp' && } + {step === 'waitList' && } + {step === 'inviteOnly' && ( + <> +

{t('common:hi')}

+

{t('common:invite_only.text', { appName: config.name })}

+ + )} + {step !== 'inviteOnly' && step !== 'waitList' && } + + ) : ( + <> + {t(`common:error.${error.type}`)} + + {t('common:sign_in')} + + + + )} +
+ ); +}; + +export default SignIn; diff --git a/frontend/src/modules/auth/oauth-options.tsx b/frontend/src/modules/auth/oauth-options.tsx new file mode 100644 index 000000000..7d3fe5638 --- /dev/null +++ b/frontend/src/modules/auth/oauth-options.tsx @@ -0,0 +1,89 @@ +import { useParams, useSearch } from '@tanstack/react-router'; +import { config } from 'config'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { githubSignInUrl, googleSignInUrl, microsoftSignInUrl } from '~/api/auth'; +import { acceptInvite } from '~/api/general'; +import { Button } from '~/modules/ui/button'; +import { SignInRoute } from '~/routes/authentication'; +import { useThemeStore } from '~/store/theme'; +import type { Step } from '.'; +export type OauthProviderOptions = (typeof config.oauthProviderOptions)[number]; + +type OauthProvider = { + id: OauthProviderOptions; + name: string; + url: string; +}; + +export const oauthProviders: OauthProvider[] = [ + { id: 'GITHUB', name: 'Github', url: githubSignInUrl }, + { id: 'GOOGLE', name: 'Google', url: googleSignInUrl }, + { id: 'MICROSOFT', name: 'Microsoft', url: microsoftSignInUrl }, +]; + +interface OauthOptionsProps { + actionType: Step; +} + +const OauthOptions = ({ actionType = 'signIn' }: OauthOptionsProps) => { + const { t } = useTranslation(); + const { mode } = useThemeStore(); + const { token }: { token: string } = useParams({ strict: false }); + + const [loading, setLoading] = useState(false); + const invertClass = mode === 'dark' ? 'invert' : ''; + let redirect = ''; + if (token) { + const searchResult = useSearch({ + from: SignInRoute.id, + }); + redirect = searchResult.redirect ?? ''; + } + + const redirectQuery = redirect ? `?redirect=${redirect}` : ''; + + return ( + <> +
+ {t('common:or')} +
+ +
+ {config.enabledOauthProviders.map((id) => { + const option = oauthProviders.find((provider) => provider.id === id); + if (!option) return; + + return ( + + ); + })} +
+ + ); +}; + +export default OauthOptions; diff --git a/frontend/src/modules/auth/password-strength.tsx b/frontend/src/modules/auth/password-strength.tsx new file mode 100644 index 000000000..f6e72b2fa --- /dev/null +++ b/frontend/src/modules/auth/password-strength.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import zxcvbn from 'zxcvbn'; + +interface PasswordStrengthProps { + className?: string; + password: string; + userInputs?: string[]; + barColors?: string[]; + minLength?: number; +} + +const PasswordStrength = ({ + password, + userInputs = [], + barColors = ['#cccccc30', '#ef4836', '#f6b44d', '#2b90ef', '#25c281'], + minLength = 4, +}: PasswordStrengthProps) => { + const [score, setScore] = useState(0); + + useEffect(() => { + const result = zxcvbn(password, userInputs); + setScore(result.score); + }, [password]); + + return ( +
minLength ? '' : 'opacity-50'}`}> + {[1, 2, 3, 4].map((el) => ( +
= el ? barColors[score] : barColors[0], + }} + /> + ))} +
+ ); +}; + +export default PasswordStrength; diff --git a/frontend/src/modules/auth/reset-password.tsx b/frontend/src/modules/auth/reset-password.tsx new file mode 100644 index 000000000..1a9b36a7c --- /dev/null +++ b/frontend/src/modules/auth/reset-password.tsx @@ -0,0 +1,116 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import * as z from 'zod'; +import { Button } from '~/modules/ui/button'; +import AuthPage from './auth-page'; + +import { passwordSchema } from 'backend/lib/common-schemas'; +import { config } from 'config'; +import { ArrowRight, Loader2 } from 'lucide-react'; +import { Suspense, lazy, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import type { ApiError } from '~/api'; +import { resetPassword as baseResetPassword } from '~/api/auth'; +import { checkToken as baseCheckToken } from '~/api/general'; +import { useMutation } from '~/hooks/use-mutations'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/form'; +import { Input } from '~/modules/ui/input'; + +const PasswordStrength = lazy(() => import('~/modules/auth/password-strength')); + +const formSchema = z.object({ + password: passwordSchema, +}); + +const ResetPassword = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { token }: { token: string } = useParams({ strict: false }); + + const [email, setEmail] = useState(''); + const [tokenError, setError] = useState(null); + + // Check reset password token and get email + const { mutate: checkToken } = useMutation({ + mutationFn: baseCheckToken, + onSuccess: (result) => setEmail(result.email), + onError: (error) => setError(error), + }); + + // Reset password and sign in + const { mutate: resetPassword, isPending } = useMutation({ + mutationFn: baseResetPassword, + onSuccess: () => { + toast.success(t('common:success.password_reset')); + navigate({ to: config.defaultRedirectPath }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + password: '', + }, + }); + + // Submit new password + const onSubmit = (values: z.infer) => { + const { password } = values; + resetPassword({ token, password }); + }; + + useEffect(() => { + if (!token) return; + + checkToken(token); + }, [token]); + + return ( + +
+

+ {t('common:reset_password')}
{' '} + {email && ( + + {t('common:for')} {email} + + )} +

+ {email ? ( + + ( + + +
+ + + + +
+
+ +
+ )} + /> + + + ) : ( +
+ {tokenError && {t(`common:error.${tokenError.type}`)}} + {isPending && } +
+ )} + +
+ ); +}; + +export default ResetPassword; diff --git a/frontend/src/modules/auth/sign-in-form.tsx b/frontend/src/modules/auth/sign-in-form.tsx new file mode 100644 index 000000000..1105953bd --- /dev/null +++ b/frontend/src/modules/auth/sign-in-form.tsx @@ -0,0 +1,176 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { authBodySchema } from 'backend/modules/auth/schema'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type * as z from 'zod'; + +import { Button } from '~/modules/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/form'; +import { Input } from '~/modules/ui/input'; + +import { config } from 'config'; +import { ArrowRight, ChevronDown, Send } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; +import { sendResetPasswordEmail as baseSendResetPasswordEmail, signIn as baseSignIn } from '~/api/auth'; +import { useMutation } from '~/hooks/use-mutations'; +import { dialog } from '~/modules/common/dialoger/state'; +import { SignInRoute } from '~/routes/authentication'; +import { useUserStore } from '~/store/user'; +import type { MeUser } from '~/types'; +import type { TokenData } from '.'; + +const formSchema = authBodySchema; + +export const SignInForm = ({ tokenData, email, setStep }: { tokenData: TokenData | null; email: string; setStep: (step: string) => void }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setUser, lastUser, clearLastUser } = useUserStore(); + + const { redirect } = useSearch({ from: SignInRoute.id }); + + const { mutate: signIn, isPending } = useMutation({ + mutationFn: baseSignIn, + onSuccess: (signedInUser) => { + setUser(signedInUser as MeUser); + + // Redirect to the invite page if token is present + // Otherwise, redirect to a redirect URL or to home + const to = tokenData ? '/auth/invite/$token' : redirect || config.defaultRedirectPath; + const params = { token: tokenData?.token }; + + navigate({ to, params, replace: true }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: email, + password: '', + }, + }); + + const onSubmit = (values: z.infer) => { + const token = tokenData?.token; + signIn({ ...values, token }); + }; + + const cancel = () => { + clearLastUser(); + setStep('check'); + }; + + useEffect(() => { + if (tokenData?.email) form.setValue('email', tokenData.email); + }, [tokenData]); + + return ( +
+

+ {tokenData ? t('common:invite_sign_in') : lastUser ? t('common:welcome_back') : t('common:sign_in_as')}
+ {!tokenData && ( + + )} +

+ + ( + + + + + + + )} + /> + ( + // Custom css due to html injection by browser extensions + + + + + + + )} + /> + + + + + + + ); +}; + +export const ResetPasswordRequest = ({ email }: { email: string }) => { + const { t } = useTranslation(); + const resetEmailRef = useRef(email); + + const { mutate: sendResetPasswordEmail, isPending } = useMutation({ + mutationFn: baseSendResetPasswordEmail, + onSuccess: () => { + toast.success(t('common:success.reset_link_sent')); + dialog.remove(); + }, + }); + + const handleResetRequestSubmit = () => { + // TODO maybe find a better way + dialog.update('send-reset-password', { + content: ( +
+ +
+ ), + }); + sendResetPasswordEmail(resetEmailRef.current); + }; + + const openDialog = () => { + dialog( +
+ { + resetEmailRef.current = e.target.value; + }} + required + /> + +
, + { + id: 'send-reset-password', + className: 'md:max-w-xl', + title: t('common:reset_password'), + text: t('common:reset_password.text'), + }, + ); + }; + + return ( + + ); +}; diff --git a/frontend/src/modules/auth/sign-out.tsx b/frontend/src/modules/auth/sign-out.tsx new file mode 100644 index 000000000..652269867 --- /dev/null +++ b/frontend/src/modules/auth/sign-out.tsx @@ -0,0 +1,30 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { signOut } from '~/api/auth'; +import { useDraftStore } from '~/store/draft'; +import { useUserStore } from '~/store/user'; +import type { MeUser } from '~/types'; + +export const signOutUser = async () => { + useUserStore.setState({ user: null as unknown as MeUser }); + await signOut(); + useDraftStore.getState().clearForms(); // Clear all drafts when signing out +}; + +const SignOut = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + useEffect(() => { + signOutUser().then(() => { + toast.success(t('common:success.signed_out')); + navigate({ to: '/about', replace: true }); + }); + }, []); + + return null; +}; + +export default SignOut; diff --git a/frontend/src/modules/auth/sign-up-form.tsx b/frontend/src/modules/auth/sign-up-form.tsx new file mode 100644 index 000000000..19c5624cf --- /dev/null +++ b/frontend/src/modules/auth/sign-up-form.tsx @@ -0,0 +1,144 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { authBodySchema } from 'backend/modules/auth/schema'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type * as z from 'zod'; + +import { config } from 'config'; +import { ArrowRight, ChevronDown } from 'lucide-react'; +import { Suspense, lazy, useEffect } from 'react'; +import { signUp as baseSignUp } from '~/api/auth'; +import { useMutation } from '~/hooks/use-mutations'; +import { dialog } from '~/modules/common/dialoger/state'; +import { Button } from '~/modules/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/form'; +import { Input } from '~/modules/ui/input'; +import type { TokenData } from '.'; +import { LegalText } from '../marketing/legals'; + +const PasswordStrength = lazy(() => import('~/modules/auth/password-strength')); + +const formSchema = authBodySchema; + +export const SignUpForm = ({ tokenData, email, setStep }: { tokenData: TokenData | null; email: string; setStep: (step: string) => void }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { mutate: signUp, isPending } = useMutation({ + mutationFn: baseSignUp, + onSuccess: () => { + const to = tokenData ? '/auth/invite/$token' : '/auth/verify-email'; + + navigate({ + to, + replace: true, + params: { + token: tokenData?.token, + }, + }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: email, + password: '', + }, + }); + + const onSubmit = (values: z.infer) => { + signUp({ + ...values, + token: tokenData?.token, + }); + }; + + useEffect(() => { + if (tokenData?.email) { + form.setValue('email', tokenData.email); + } + }, [tokenData]); + + return ( +
+

+ {tokenData ? t('common:invite_create_account') : `${t('common:create_resource', { resource: t('common:account').toLowerCase() })}?`}
+ {!tokenData && ( + + )} +

+ + + + + ( + + + + + + + )} + /> + ( + // Custom css due to html injection by browser extensions + + +
+ + + + +
+
+ +
+ )} + /> + + + + ); +}; + +export const LegalNotice = () => { + const { t } = useTranslation(); + + const openDialog = (mode: 'terms' | 'privacy') => () => { + const dialogComponent = ; + const dialogTitle = mode; + + dialog(dialogComponent, { + className: 'md:max-w-xl', + title: dialogTitle, + }); + }; + + return ( +

+ {t('common:legal_notice.text')} + + & + + of {config.company.name}. +

+ ); +}; diff --git a/frontend/src/modules/auth/verify-email.tsx b/frontend/src/modules/auth/verify-email.tsx new file mode 100644 index 000000000..ebb96aa89 --- /dev/null +++ b/frontend/src/modules/auth/verify-email.tsx @@ -0,0 +1,60 @@ +import { useNavigate, useParams } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { verifyEmail as baseVerifyEmail } from '~/api/auth'; +import { useMutation } from '~/hooks/use-mutations'; +import { Button } from '~/modules/ui/button'; +import AuthPage from './auth-page'; + +const VerifyEmail = () => { + const { t } = useTranslation(); + const { token }: { token: string } = useParams({ strict: false }); + const navigate = useNavigate(); + + const { mutate: verifyEmail, error } = useMutation({ + mutationFn: baseVerifyEmail, + onSuccess: () => { + toast.success(t('common:success.email_verified')); + navigate({ to: '/welcome' }); + }, + }); + + const resendEmail = () => { + verifyEmail({ token, resend: true }); + }; + + useEffect(() => { + if (!token) return; + verifyEmail({ token }); + }, []); + + if (token) { + if (error) { + return ( + +
+

{t('common:error.unable_to_verify')}

+

{t('common:error.token_invalid_request_new')}

+ +
+
+ ); + } + + return null; + } + + return ( + +
+

{t('common:almost_there')}

+

{t('common:verify_email_notice.text')}

+
+
+ ); +}; + +export default VerifyEmail; diff --git a/frontend/src/modules/common/accept-invite.tsx b/frontend/src/modules/common/accept-invite.tsx new file mode 100644 index 000000000..25d074782 --- /dev/null +++ b/frontend/src/modules/common/accept-invite.tsx @@ -0,0 +1,89 @@ +import { Link, useNavigate, useParams } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; + +import type { checkTokenSchema } from 'backend/modules/general/schema'; +import { config } from 'config'; +import { ArrowRight, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import type { z } from 'zod'; +import type { ApiError } from '~/api'; +import { acceptInvite as baseAcceptInvite } from '~/api/general'; +import { checkToken as baseCheckToken } from '~/api/general'; +import { useMutation } from '~/hooks/use-mutations'; +import { cn } from '~/lib/utils'; +import AuthPage from '../auth/auth-page'; +import { Button, buttonVariants } from '../ui/button'; +import Spinner from './spinner'; + +type TokenData = z.infer; + +const AcceptInvite = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { token }: { token: string } = useParams({ strict: false }); + + const [tokenData, setTokenData] = useState(null); + const [error, setError] = useState(null); + + const { mutate: checkToken, isPending: isChecking } = useMutation({ + mutationFn: baseCheckToken, + onSuccess: (result) => setTokenData(result), + onError: (error) => setError(error), + }); + + const { mutate: acceptInvite, isPending } = useMutation({ + mutationFn: baseAcceptInvite, + onSuccess: () => { + toast.success(t('common:invitation_accepted')); + navigate({ + to: tokenData?.organizationSlug ? `/${tokenData.organizationSlug}` : config.defaultRedirectPath, + }); + }, + onError: (error) => setError(error), + }); + + const onSubmit = () => { + acceptInvite({ token }); + }; + + useEffect(() => { + if (!token) return; + checkToken(token); + }, [token]); + + if (isChecking) return ; + + return ( + +

{t('common:accept_invite')}

+ +

{t('common:accept_invite_text', { email: tokenData?.email, organization: tokenData?.organizationName })}

+ + {tokenData?.email && !error ? ( +
+ +
+ ) : ( +
+ {/* TODO: we need a render error message component ? */} + {error && ( + <> + {t(`common:error.${error.type}`)} + + {t('common:sign_in')} + + + + )} + {isPending && } +
+ )} +
+ ); +}; + +export default AcceptInvite; diff --git a/frontend/src/modules/common/app-alert.tsx b/frontend/src/modules/common/app-alert.tsx new file mode 100644 index 000000000..232a01907 --- /dev/null +++ b/frontend/src/modules/common/app-alert.tsx @@ -0,0 +1,39 @@ +import type { VariantProps } from 'class-variance-authority'; +import { type LucideProps, X } from 'lucide-react'; +import type React from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '~/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '~/modules/ui/alert'; +import { useAlertStore } from '~/store/alert'; +import type { alertVariants } from '../ui/alert'; +import { Button } from '../ui/button'; + +export type AppAlert = { + className?: string; + id: string; + Icon?: React.ElementType; + children: React.ReactNode; + title?: string; + variant?: VariantProps['variant']; +}; + +export const AppAlert = ({ id, Icon, children, className = '', title = '', variant = 'default' }: AppAlert) => { + const { t } = useTranslation(); + const { alertsSeen, setAlertSeen, downAlert } = useAlertStore(); + const showAlert = !alertsSeen.includes(id); + const closeAlert = () => setAlertSeen(id); + + if (downAlert || !showAlert) return; + + return ( + + + {Icon && } + {title && {t(title)}} + + {children && {children}} + + ); +}; diff --git a/frontend/src/modules/common/app-content.tsx b/frontend/src/modules/common/app-content.tsx new file mode 100644 index 000000000..19f8068ff --- /dev/null +++ b/frontend/src/modules/common/app-content.tsx @@ -0,0 +1,65 @@ +import { Outlet, useMatches } from '@tanstack/react-router'; +import { Info } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppAlert } from '~/modules/common/app-alert'; +import { AppFooter } from '~/modules/common/app-footer'; +import { useNavigationStore } from '~/store/navigation'; + +export const AppContent = () => { + const { t } = useTranslation(); + const { activeSheet, keepMenuOpen, setSheet, focusView } = useNavigationStore(); + + const clickContentRef = useRef(null); + const [showFooter, setShowFooter] = useState(false); + + // Move content to the right when the menu is open + const addPadding = keepMenuOpen && activeSheet?.id === 'menu' ? 'xl:pl-80' : 'pl-0'; + + // Close the sheet when clicking in content + useEffect(() => { + const handleClickContent = (e: MouseEvent) => { + if (clickContentRef.current?.contains(e.target as Node)) { + setSheet(null, 'routeChange'); + } + }; + + document.addEventListener('click', handleClickContent); + return () => { + document.removeEventListener('click', handleClickContent); + }; + }, []); + + // Custom hook for setting document title + const matches = useMatches(); + + useEffect(() => { + const hide = matches.find((match) => match.staticData.hideFooter); + if (!!hide === showFooter) setShowFooter(!showFooter); + }, [matches]); + + return ( +
+
+
+ {/* Prerelease heads up */} + + {t('common:prerelease')} + {t('common:experiment_notice.text')} + + + +
+ {showFooter && } +
+
+ ); +}; diff --git a/frontend/src/modules/common/app-footer.tsx b/frontend/src/modules/common/app-footer.tsx new file mode 100644 index 000000000..9ba401807 --- /dev/null +++ b/frontend/src/modules/common/app-footer.tsx @@ -0,0 +1,94 @@ +import { Link } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; +import { cn } from '~/lib/utils'; +import ContactForm from '~/modules/common/contact-form/contact-form'; +import Logo from '~/modules/common/logo'; +import UserTheme from '~/modules/common/user-theme'; +import { dialog } from './dialoger/state'; +import UserLanguage from './user-language'; + +export interface FooterLinkProps { + id: string; + href: string; +} + +export const FooterLink = ({ id, href }: FooterLinkProps) => { + const { t } = useTranslation(); + + return ( +
  • + + {t(`common:${id}`)} + +
  • + ); +}; + +// Default footer links +const defaultFooterLinks: FooterLinkProps[] = [ + { id: 'about', href: '/about' }, + { id: 'legal', href: '/legal' }, +]; + +interface FooterLinksProps { + links?: FooterLinkProps[]; + className?: string; +} + +// Row of footer links including a contact button +export const FooterLinks = ({ links = defaultFooterLinks, className = '' }: FooterLinksProps) => { + const { t } = useTranslation(); + + const handleOpenContactForm = () => { + dialog(, { + id: 'contact-form', + drawerOnMobile: false, + className: 'sm:max-w-5xl', + title: t('common:contact_us'), + text: t('common:contact_us.text'), + }); + }; + // Not on every page we have footer e.g. workspace + // useEffect(() => { + // document.addEventListener('openContactForm', handleOpenContactForm); + // return () => { + // document.removeEventListener('openContactForm', handleOpenContactForm); + // }; + // }, []); + + return ( +
      + {links.map((link) => ( + + ))} +
    • + +
    • +
    + ); +}; + +// App Footer component +export const AppFooter = ({className = '' }) => { + return ( +
    +
    + +
    |
    + + + +
    |
    + +
    + +
    + ); +}; diff --git a/frontend/src/modules/common/app-nav-button.tsx b/frontend/src/modules/common/app-nav-button.tsx new file mode 100644 index 000000000..208d426a8 --- /dev/null +++ b/frontend/src/modules/common/app-nav-button.tsx @@ -0,0 +1,47 @@ +import { config } from 'config'; +import { Button } from '~/modules/ui/button'; +import { useThemeStore } from '~/store/theme'; +import { useUserStore } from '~/store/user'; +import { AvatarWrap } from './avatar-wrap'; + +import { useTranslation } from 'react-i18next'; + +import { cn } from '~/lib/utils'; +import type { NavItem } from './app-nav'; +import AppNavLoader from './app-nav-loader'; +import { TooltipButton } from './tooltip-button'; + +interface NavButtonProps { + navItem: NavItem; + isActive: boolean; + onClick: (id: string) => void; +} + +export const NavButton = ({ navItem, isActive, onClick }: NavButtonProps) => { + const { t } = useTranslation(); + const user = useUserStore((state) => state.user); + const { theme } = useThemeStore(); + + const navIconColor = theme !== 'none' ? 'text-primary-foreground' : ''; + const activeClass = isActive ? 'bg-accent/20 hover:bg-accent/20' : ''; + + return ( + + + + ); +}; diff --git a/frontend/src/modules/common/app-nav-loader.tsx b/frontend/src/modules/common/app-nav-loader.tsx new file mode 100644 index 000000000..fc944e50a --- /dev/null +++ b/frontend/src/modules/common/app-nav-loader.tsx @@ -0,0 +1,53 @@ +import { useIsFetching } from '@tanstack/react-query'; +import { config } from 'config'; +import { Home } from 'lucide-react'; +import { useEffect } from 'react'; +import useMounted from '~/hooks/use-mounted'; +import router from '~/lib/router'; +import { useNavigationStore } from '~/store/navigation'; +import Logo from './logo'; +import { sheet } from './sheeter/state'; + +const AppNavLoader = () => { + const { hasWaited } = useMounted(); + const { setSheet, navLoading, setLoading, setFocusView } = useNavigationStore(); + const isFetching = useIsFetching(); + + // Show loading spinner when fetching data or navigating + const isLoading = isFetching > 0 || navLoading; + + useEffect(() => { + // TODO: move this to a more general location? + router.subscribe('onBeforeLoad', ({ pathChanged, toLocation, fromLocation }) => { + if (toLocation.pathname !== fromLocation.pathname) { + // Disable focus view + setFocusView(false); + // Remove sheets in content + sheet.remove(); + // Remove navigation sheet + setSheet(null, 'routeChange'); + } + pathChanged && setLoading(true); + }); + router.subscribe('onLoad', () => { + setLoading(false); + }); + }, []); + + return ( + <> + + + + ); +}; + +export default AppNavLoader; diff --git a/frontend/src/modules/common/app-nav.tsx b/frontend/src/modules/common/app-nav.tsx new file mode 100644 index 000000000..9eee91e09 --- /dev/null +++ b/frontend/src/modules/common/app-nav.tsx @@ -0,0 +1,102 @@ +import { useNavigate } from '@tanstack/react-router'; +import { Home, type LucideProps, Menu, Search, User } from 'lucide-react'; +import { Fragment } from 'react'; +import { useThemeStore } from '~/store/theme'; +import { config } from 'config'; +import { Suspense } from 'react'; + +import useLazyComponent from '~/hooks/use-lazy-component'; // Adjust the import path accordingly +import { useBreakpoints } from '~/hooks/use-breakpoints'; +import { cn } from '~/lib/utils'; +import { dialog } from '~/modules/common/dialoger/state'; +import { useNavigationStore } from '~/store/navigation'; +import { NavSheet } from './nav-sheet'; +import { SheetAccount } from './nav-sheet/sheet-account'; +import { SheetMenu } from './nav-sheet/sheet-menu'; + +import useMounted from '~/hooks/use-mounted'; +import { NavButton } from './app-nav-button'; +import { AppSearch } from './app-search'; + +export type NavItem = { + id: string; + icon: React.ElementType; + sheet?: React.ReactNode; + href?: string; + mirrorOnMobile?: boolean; +}; + +export const navItems: NavItem[] = [ + { id: 'menu', sheet: , icon: Menu }, + { id: 'home', icon: Home, href: '/' }, + { id: 'search', icon: Search }, + { id: 'account', sheet: , icon: User, mirrorOnMobile: true }, +]; + +const AppNav = () => { + const navigate = useNavigate(); + const { hasStarted } = useMounted(); + const isSmallScreen = useBreakpoints('max', 'xl'); + const { activeSheet, setSheet, focusView } = useNavigationStore(); + const { theme } = useThemeStore(); + const navBackground = theme !== 'none' ? 'bg-primary' : 'bg-primary-foreground'; + + const navButtonClick = (navItem: NavItem) => { + // Search is a special case, it will open a dialog + if (navItem.id === 'search') { + return dialog(, { + className: 'sm:max-w-2xl p-0 border-0', + drawerOnMobile: false, + refocus: false, + hideClose: true, + autoFocus: !isSmallScreen, + }); + } + + // If its a route, navigate to it + if (navItem.href) return navigate({ to: navItem.href }); + + // Open new sheet + const isNew = !activeSheet || activeSheet.id !== navItem.id; + setSheet(isNew ? navItem : null); + }; + const DebugToolbars = config.mode === 'development' ? useLazyComponent(() => import('~/modules/common/debug-toolbars'), 1000) : () => null; + + return ( + <> + + + + ); +}; + +export default AppNav; diff --git a/frontend/src/modules/common/app-search.tsx b/frontend/src/modules/common/app-search.tsx new file mode 100644 index 000000000..a9938d30d --- /dev/null +++ b/frontend/src/modules/common/app-search.tsx @@ -0,0 +1,197 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import type { entitySuggestionSchema } from 'backend/modules/general/schema'; +import type { Entity } from 'backend/types/common'; +import { config } from 'config'; +import { History, Loader2, Search, X } from 'lucide-react'; +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import StickyBox from 'react-sticky-box'; +import type { z } from 'zod'; +import { getSuggestions } from '~/api/general'; +import { dialog } from '~/modules/common/dialoger/state'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator } from '~/modules/ui/command'; +import { useNavigationStore } from '~/store/navigation'; +import { ScrollArea } from '../ui/scroll-area'; +import { AvatarWrap } from './avatar-wrap'; +import ContentPlaceholder from './content-placeholder'; + +type SuggestionType = z.infer; + +interface SuggestionSection { + id: 'users' | 'organizations' | 'workspaces' | 'projects'; + label: string; + type: Entity; +} + +const suggestionSections: SuggestionSection[] = [ + { id: 'users', label: 'common:users', type: 'USER' }, + { id: 'organizations', label: 'common:organizations', type: 'ORGANIZATION' }, + { id: 'workspaces', label: 'common:workspaces', type: 'WORKSPACE' }, + { id: 'projects', label: 'common:projects', type: 'PROJECT' }, +]; + +export const AppSearch = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const scrollAreaRef = useRef(null); + + const [searchValue, setSearchValue] = useState(''); + + const recentSearches = useNavigationStore((state) => state.recentSearches); + + const deleteItemFromList = (item: string) => { + useNavigationStore.setState((state) => { + const searches = [...state.recentSearches]; + const index = searches.indexOf(item); + if (index === -1) return; + searches.splice(index, 1); + return { ...state, recentSearches: searches }; + }); + }; + + const updateRecentSearches = (value: string) => { + if (!value) return; + if (value.replaceAll(' ', '').length < 3) return; + const hasSubstringMatch = recentSearches.some((element) => element.toLowerCase().includes(value)); + if (hasSubstringMatch) return; + useNavigationStore.setState((state) => { + const searches = [...state.recentSearches]; + + if (searches.includes(value)) { + searches.splice(searches.indexOf(value), 1); + searches.push(value); + } else { + searches.push(value); + if (searches.length > 5) searches.shift(); + } + return { ...state, recentSearches: searches }; + }); + }; + + const { data: suggestions, isFetching } = useQuery({ + initialData: { items: [], total: 0 }, + queryKey: ['search', searchValue], + queryFn: () => getSuggestions(searchValue), + enabled: searchValue.length > 0, + }); + + const onSelectSuggestion = (suggestion: SuggestionType) => { + // Update recent searches with the search value + updateRecentSearches(searchValue); + + navigate({ + to: suggestion.entity === 'ORGANIZATION' ? '/$idOrSlug' : `/${suggestion.entity.toLowerCase()}/$idOrSlug`, + resetScroll: false, + params: { + idOrSlug: suggestion.slug, + }, + }); + + dialog.remove(false); + }; + + useEffect(() => { + if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0; + }, [suggestions]); + + return ( + + { + const historyIndexes = recentSearches.map((_, index) => index); + if (historyIndexes.includes(Number.parseInt(searchValue))) { + setSearchValue(recentSearches[+searchValue]); + return; + } + setSearchValue(searchValue); + }} + /> + + {isFetching && ( + + + + )} + { + + {suggestions.total === 0 && ( + <> + {!!searchValue.length && ( + + + + )} + {searchValue.length === 0 && ( + + + + )} + {!!recentSearches.length && ( + +
    {t('common:history')}
    + {recentSearches.map((search, index) => ( + setSearchValue(search)} className="justify-between"> +
    + + {search} +
    +
    + {index} + +
    +
    + ))} +
    + )} + + )} + {suggestions.total > 0 && ( + <> + {suggestionSections.map((section) => { + return ( + + + + {t(section.label)} + {suggestions.items + .filter((el) => el.entity === section.type) + .map((suggestion: SuggestionType) => ( + onSelectSuggestion(suggestion)}> +
    + + {suggestion.name} +
    +
    + ))} +
    +
    + ); + })} + + )} +
    + } +
    +
    + ); +}; diff --git a/frontend/src/modules/common/app.tsx b/frontend/src/modules/common/app.tsx new file mode 100644 index 000000000..8a6d7c554 --- /dev/null +++ b/frontend/src/modules/common/app.tsx @@ -0,0 +1,28 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import { AppContent } from '~/modules/common/app-content'; + +import AppNav from '~/modules/common/app-nav'; +import SSE from '~/modules/common/sse'; +import ElectricProvider from './electric'; +import ErrorNotice from './error-notice'; +import { SSEProvider } from './sse/provider'; + +const App = () => { + return ( +
    + } + > + + + + + + + + +
    + ); +}; + +export default App; diff --git a/frontend/src/modules/common/aside-anchor.tsx b/frontend/src/modules/common/aside-anchor.tsx new file mode 100644 index 000000000..e911962a3 --- /dev/null +++ b/frontend/src/modules/common/aside-anchor.tsx @@ -0,0 +1,17 @@ +import { cn } from '~/lib/utils'; + +interface AsideAnchorProps { + className?: string; + id: string; + children?: React.ReactNode; +} + +export const AsideAnchor = ({ id, className, children }: AsideAnchorProps) => { + return ( + // aside-anchor class is used to correct the offset of the anchor +
    +
    + {children} +
    + ); +}; diff --git a/frontend/src/modules/common/aside-nav.tsx b/frontend/src/modules/common/aside-nav.tsx new file mode 100644 index 000000000..cab0c85e1 --- /dev/null +++ b/frontend/src/modules/common/aside-nav.tsx @@ -0,0 +1,55 @@ +import { Link } from '@tanstack/react-router'; +import type { LucideProps } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useScrollSpy } from '~/hooks/use-scroll-spy'; +import { cn } from '~/lib/utils'; +import { buttonVariants } from '../ui/button'; + +interface AsideNavProps { + className?: string; + tabs: { + id: string; + label: string; + resource?: string; + icon?: React.ElementType; + }[]; +} + +export const AsideNav = ({ tabs, className }: AsideNavProps) => { + const { t } = useTranslation(); + const sectionIds = tabs.map((tab) => tab.id); + const { activeHash } = useScrollSpy({ sectionIds, autoUpdateHash: true }); + + // console.log(activeHash, 'TEST') + // TODO: perhaps move this somehow to useScrollSpy and add a stop when section is already in view + // TODO2: add option to silently update the hash without scrolling on initial mount with sectionIds[0] (if no hash is present) + // If the hash already matches but the user is not at the section, clear and re-set the hash + const handleMismatch = (e: React.MouseEvent<'a', MouseEvent>, target: string) => { + e.preventDefault(); + const element = document.getElementById(`${target}-anchor`); + if (!element) return; + element.scrollIntoView(); + }; + + return ( +
    + {tabs.map(({ id, label, icon, resource }) => { + const btnClass = `${id.includes('delete') && 'text-red-600'} hover:bg-accent/50 w-full justify-start text-left`; + const Icon = icon; + return ( + handleMismatch(e, id)} + activeOptions={{ exact: true, includeHash: true }} + activeProps={{ className: 'bg-secondary' }} + > + {Icon && } {t(label, { resource: t(resource || '').toLowerCase() })} + + ); + })} +
    + ); +}; diff --git a/frontend/src/modules/common/avatar-wrap.tsx b/frontend/src/modules/common/avatar-wrap.tsx new file mode 100644 index 000000000..f10ea5fd3 --- /dev/null +++ b/frontend/src/modules/common/avatar-wrap.tsx @@ -0,0 +1,37 @@ +import type { AvatarProps } from '@radix-ui/react-avatar'; +import type { Entity } from 'backend/types/common'; +import { memo, useMemo } from 'react'; +import { cn, getColorClass } from '~/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '~/modules/ui/avatar'; + +export interface AvatarWrapProps extends AvatarProps { + id?: string; + type?: Entity; + name?: string | null; + url?: string | null; + backgroundColor?: string; + className?: string; +} + +const AvatarWrap = memo(({ type, id, name, url, className, backgroundColor, ...props }: AvatarWrapProps) => { + const avatarBackground = useMemo(() => getColorClass(id), [id]); + return ( + + {url ? ( + + ) : ( + + {name} +
    + {name?.charAt(0).toUpperCase() || '-'} +
    +
    + )} +
    + ); +}); + +export { AvatarWrap }; diff --git a/frontend/src/modules/common/background-picker.tsx b/frontend/src/modules/common/background-picker.tsx new file mode 100644 index 000000000..c60c50ef2 --- /dev/null +++ b/frontend/src/modules/common/background-picker.tsx @@ -0,0 +1,140 @@ +import { Paintbrush } from 'lucide-react'; +import { useMemo } from 'react'; +import { cn } from '~/lib/utils'; +import { Button } from '~/modules/ui/button'; +import { Input } from '~/modules/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover'; +import { RadioGroup, RadioGroupItem } from '~/modules/ui/radio-group'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/modules/ui/tabs'; + +type PickerType = 'solid' | 'gradient' | 'image'; + +interface BackgroundPickerProps { + background: string; + setBackground: (background: string) => void; + className?: string; + showText?: boolean; + options?: PickerType[]; +} + +export function BackgroundPicker({ + background, + setBackground, + showText, + className, + options = ['solid', 'gradient', 'image'], +}: BackgroundPickerProps) { + const solids = ['#E2E2E2', '#ff75c3', '#ffa647', '#ffe83f', '#9fff5b', '#70e2ff', '#cd93ff', '#09203f']; + + const gradients = [ + 'linear-gradient(to bottom right,#accbee,#e7f0fd)', + 'linear-gradient(to bottom right,#d5d4d0,#d5d4d0,#eeeeec)', + 'linear-gradient(to bottom right,#000000,#434343)', + 'linear-gradient(to bottom right,#09203f,#537895)', + 'linear-gradient(to bottom right,#AC32E4,#7918F2,#4801FF)', + 'linear-gradient(to bottom right,#f953c6,#b91d73)', + 'linear-gradient(to bottom right,#ee0979,#ff6a00)', + 'linear-gradient(to bottom right,#F00000,#DC281E)', + 'linear-gradient(to bottom right,#00c6ff,#0072ff)', + 'linear-gradient(to bottom right,#4facfe,#00f2fe)', + 'linear-gradient(to bottom right,#0ba360,#3cba92)', + 'linear-gradient(to bottom right,#FDFC47,#24FE41)', + 'linear-gradient(to bottom right,#8a2be2,#0000cd,#228b22,#ccff00)', + 'linear-gradient(to bottom right,#40E0D0,#FF8C00,#FF0080)', + 'linear-gradient(to bottom right,#fcc5e4,#fda34b,#ff7882,#c8699e,#7046aa,#0c1db8,#020f75)', + 'linear-gradient(to bottom right,#ff75c3,#ffa647,#ffe83f,#9fff5b,#70e2ff,#cd93ff)', + ]; + + const images = [ + 'url(https://images.unsplash.com/photo-1691200099282-16fd34790ade?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)', + 'url(https://images.unsplash.com/photo-1691226099773-b13a89a1d167?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90', + 'url(https://images.unsplash.com/photo-1688822863426-8c5f9b257090?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)', + 'url(https://images.unsplash.com/photo-1691225850735-6e4e51834cad?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)', + ]; + + const defaultTab = useMemo(() => { + if (background.includes('url')) return 'image'; + if (background.includes('gradient')) return 'gradient'; + return 'solid'; + }, [background]); + + return ( + + + + + + + {options.length > 1 && ( + + {options.map((pickerOption) => ( + + {pickerOption.charAt(0).toUpperCase() + pickerOption.slice(1)} + + ))} + + )} + + + + {solids.map((bg) => ( + setBackground(bg)} + aria-label={bg} + /> + ))} + + + + + + {gradients.map((bg) => ( + setBackground(bg)} + /> + ))} + + + + + + {images.map((bg) => ( + setBackground(bg)} + /> + ))} + + + + + setBackground(e.currentTarget.value)} /> + + + ); +} diff --git a/frontend/src/modules/common/bg-animation/animation.d.ts b/frontend/src/modules/common/bg-animation/animation.d.ts new file mode 100644 index 000000000..14a653055 --- /dev/null +++ b/frontend/src/modules/common/bg-animation/animation.d.ts @@ -0,0 +1,3 @@ +export function start_cells(c: HTMLCanvasElement): void; +export function set_cell_color(color: [number, number, number]): void; +export function stop_cells(): void; diff --git a/frontend/src/modules/common/bg-animation/animation.js b/frontend/src/modules/common/bg-animation/animation.js new file mode 100644 index 000000000..21771edcb --- /dev/null +++ b/frontend/src/modules/common/bg-animation/animation.js @@ -0,0 +1,639 @@ +let canvas = null; +let gl = null; +let texs = null; +let hash_tex = null; +let framebuffer = null; +let rescale_shader = null; +let render_shader = null; +let trace_shader = null; +let last_resolution = [0, 0]; +let cell_color = [1, 0, 0]; + +// A placeholder function for renderTask when not provided +const NOOP = () => {}; + +/** + * A class for managing WebGL rendering process, monitoring performance, and adjusting rendering behavior dynamically. + */ +class WebGLRenderer { + /** + * Creates an instance of WebGLRenderer. + * @param {Function} renderTask - The function representing the rendering task. + */ + constructor(renderTask) { + // Rendering task + this.renderTask = renderTask || NOOP; + + // The sync object used to synchronize with GPU commands. + this.sync = null; + + // The start time of the current performance measurement. + this.startTime = 0; + + // The number of cycles elapsed since monitoring started. + this.cycles = 0; + + // The number of consecutive adjusted cycles within a range. + // Each time a normal cycle occurs, this number decreases. + this.adjustedCycles = 0; + + // The cycle after which the rendering starts. + this.renderStartCycle = 60; // Default value: 60 cycles (adjust as needed). + + // The maximum acceptable time per frame (in milliseconds) for maintaining target frame rate. + this.maxFrameTime = 16.67; // milliseconds (for 60 FPS) + + // The threshold for identifying a big lag (in adjusted cycles). + this.bigLagThreshold = 50; // Threshold for identifying big lag (adjust as needed) + + // Timeout duration for adjusting frame rate (in milliseconds) + this.adjustmentTimeoutDuration = 100; // Adjust timeout as needed + + // Timeout duration for checking sync (in milliseconds) + this.checkTimeoutDuration = 1; // Adjust timeout as needed + + // Flag to indicate if the rendering loop is running + this.isRunning = false; + } + + /** + * Starts the WebGL rendering process if not already running. + */ + start() { + if (!this.isRunning && gl) { + this.isRunning = true; + this.renderLoop(); + } + } + + /** + * Stops the WebGL rendering process. + */ + stop() { + this.isRunning = false; + } + + /** + * Checks if the WebGL context is available. + * If not, stops the rendering process. + */ + checkContext() { + if (!gl && this.isRunning) { + this.stop(); + } + } + + /** + * The main render loop. + */ + renderLoop() { + try { + this.checkContext(); + if (!this.isRunning) return; + + this.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + this.startTime = performance.now(); + if (this.cycles >= this.renderStartCycle) this.renderTask(); + this.checkSync(); + } catch (error) { + console.error('Error in render loop:', error); + this.stop(); + } + } + + /** + * Checks if the GPU commands have been completed and adjusts rendering behavior accordingly. + */ + checkSync() { + try { + this.checkContext(); + if (!this.isRunning) return; + + const status = gl.clientWaitSync(this.sync, 0, 0); + if (status === gl.CONDITION_SATISFIED || status === gl.ALREADY_SIGNALED) { + const endTime = performance.now(); + const elapsedTime = endTime - this.startTime; + + // Delete the sync object at the end of the cycle + gl.deleteSync(this.sync); + + if (elapsedTime > this.maxFrameTime) { + // Big lag detected, switch to fallback mechanism + if (this.adjustedCycles >= this.bigLagThreshold) { + this.handlePerformanceFallback(); + return; + } + // Lag detected, adjust frame rate + this.updateCycleCounters(true); + this.adjustFrameRate(); + } else { + // No lag, continue with requestAnimationFrame + this.updateCycleCounters(); + this.requestNextFrame(); + } + } else { + // Not yet complete, continue checking + setTimeout(() => this.checkSync(), this.checkTimeoutDuration); + } + } catch (error) { + console.error('Error in checkSync:', error); + this.stop(); + } + } + + /** + * Updates cycle counters after each frame. + * @param {boolean} adjusted - Indicates if the cycle was adjusted due to lag. + */ + updateCycleCounters(adjusted) { + try { + if (adjusted) { + this.adjustedCycles++; + } else if (this.adjustedCycles) { + this.adjustedCycles--; + } + + this.cycles++; + } catch (error) { + console.error('Error in finishedCycle:', error); + this.stop(); + } + } + + /** + * Requests the next frame to be rendered. + */ + requestNextFrame() { + try { + // Continue with next frame + requestAnimationFrame(() => this.renderLoop()); + } catch (error) { + console.error('Error in requestNextFrame:', error); + this.stop(); + } + } + + /** + * Adjusts the frame rate or applies other optimizations based on detected lag. + */ + adjustFrameRate() { + try { + // Lower frame rate or apply other optimizations + // Example: reduce frame rate by setting a longer timeout + setTimeout(() => this.requestNextFrame(), this.adjustmentTimeoutDuration); + } catch (error) { + console.error('Error in adjustFrameRate:', error); + this.stop(); + } + } + + /** + * Handles the fallback mechanism when a big lag is detected. + */ + handlePerformanceFallback() { + try { + // Switch to fallback mechanism + // Example: switch to a static image or other fallback mechanism + // Stops the WebGL rendering process. + this.stop(); + } catch (error) { + console.error('Error in handlePerformanceFallback:', error); + this.stop(); + } + } +} + +let webGLRenderer = null; + +// Configuration for circles on background +// Maximum number of circles allowed +const maxAmountOfCircles = 2000; + +// Minimum radius for circles +const minCircleRadius = 0.0625; + +// Maximum radius for circles +const maxCircleRadius = 0.15; + +// Minimum distance for touch detection +const touchDistance = minCircleRadius; + +// Calculations +const rand = (min_or_max, max) => (min_or_max ? (max ? min_or_max + (max - min_or_max) * Math.random() : min_or_max * Math.random()) : Math.random()); +const normalize = (v) => { + const mag = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + return v.map((e) => e / mag); +}; +const rand_dir = () => + normalize([rand(-1, 1) + rand(-1, 1) + rand(-1, 1), rand(-1, 1) + rand(-1, 1) + rand(-1, 1), rand(-1, 1) + rand(-1, 1) + rand(-1, 1)]); +const cross = (v1, v2) => [v1[1] * v2[2] - v1[2] * v2[1], v1[2] * v2[0] - v1[0] * v2[2], v1[0] * v2[1] - v1[1] * v2[0]]; +const distortion_dot_dir_1 = rand_dir(); +const distortion_dot_dir_2 = normalize(cross(distortion_dot_dir_1, rand_dir())); +const distortion_push_dir_1 = normalize(cross(distortion_dot_dir_1, rand_dir())); +const distortion_push_dir_2 = distortion_dot_dir_1.map((e) => -e); +const distortion_power_1 = rand(0.1, 1); +const distortion_power_2 = rand(0.1, 1); +const circles = []; +for (let i = 0; i < maxAmountOfCircles; i++) { + const pos = [rand(minCircleRadius - 1, 1 - minCircleRadius), rand(minCircleRadius - 1, 1 - minCircleRadius)]; + let touch_dist = Math.min(1 - Math.abs(pos[0]), 1 - Math.abs(pos[1])); + for (const circle of circles) { + const dx = pos[0] - circle[0]; + const dy = pos[1] - circle[1]; + touch_dist = Math.min(touch_dist, Math.sqrt(dx * dx + dy * dy) - (0.00390625 + circle[2])); + } + if (touch_dist > touchDistance) { + const radius = rand(Math.min(touch_dist, minCircleRadius), Math.min(touch_dist, maxCircleRadius)); + pos.push(radius); + circles.push(pos); + } +} +const create_shader = (vert, frag_source, uniform_names) => { + const frag = gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(frag, frag_source); + gl.compileShader(frag); + const shader = gl.createProgram(); + gl.attachShader(shader, vert); + gl.attachShader(shader, frag); + gl.linkProgram(shader); + // biome-ignore lint/suspicious/noAssignInExpressions: + uniform_names.map((name) => (shader[name] = gl.getUniformLocation(shader, name))); + return shader; +}; +const create_tex = (width, height) => { + const tex = gl.createTexture(gl.TEXTURE_2D); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32UI, width, height, 0, gl.RGBA_INTEGER, gl.UNSIGNED_INT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; +}; +const init = (c) => { + canvas = c; + gl = canvas.getContext('webgl2'); + const vert = gl.createShader(gl.VERTEX_SHADER); + gl.shaderSource( + vert, + `#version 300 es +precision highp float; +precision highp usampler2D; +precision highp int; +void main() +{ + gl_Position = vec4(((gl_VertexID == 2) ? 3. : -1.), ((gl_VertexID == 1) ? 3. : -1.), 0.5, 1.); +} +`, + ); + gl.compileShader(vert); + rescale_shader = create_shader( + vert, + `#version 300 es +precision highp float; +precision highp usampler2D; +precision highp int; +uniform vec2 resolution; +uniform usampler2D tex; +out uvec4 frag_color; +void main() +{ + frag_color = texture(tex, (gl_FragCoord.xy / resolution)); +}`, + ['resolution', 'tex'], + ); + window.rescale_shader = rescale_shader; + render_shader = create_shader( + vert, + `#version 300 es +precision highp float; +precision highp usampler2D; +precision highp int; +uniform vec2 resolution; +uniform usampler2D tex; +uniform vec3 color; +out vec4 frag_color; +void main() +{ + frag_color = (mix(0., 1., (float(texture(tex, (gl_FragCoord.xy / resolution)).x) * 0.00000000023283064371)) * vec4(color, 1.)); +} +`, + ['resolution', 'tex', 'color'], + ); + trace_shader = create_shader( + vert, + `#version 300 es +precision highp float; +precision highp usampler2D; +precision highp int; +uniform usampler2D old_tex; +uniform vec3 distortion_dot_dir_2; +uniform vec3 distortion_push_dir_1; +uniform float distortion_power_1; +uniform float time; +uniform float distortion_power_2; +uniform vec3 distortion_push_dir_2; +uniform vec2 resolution; +uniform usampler2D hash_tex; +uniform vec3 distortion_dot_dir_1; +out uvec4 frag_color; +struct Ray { + vec3 pos; + vec3 dir; +}; +struct BoxIntersection { + bool hit; + float front_dist; + float back_dist; + vec3 front_norm; +}; +float sympow(float x, float power) +{ + return (sign(x) * pow(abs(x), power)); +} +Ray progress_ray(Ray ray, float t) +{ + return Ray((ray.pos + (ray.dir * t)), ray.dir); +} +uint pcg(uint x) +{ + uint state = ((x * 747796405u) + 2891336453u); + uint word = (((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u); + return ((word >> 22u) ^ word); +} +uvec3 pcg(uvec3 x) +{ + x = ((x * 1664525u) + 1013904223u); + x.x += (x.y * x.z); + x.y += (x.z * x.x); + x.z += (x.x * x.y); + x ^= (x >> 16u); + x.x += (x.y * x.z); + x.y += (x.z * x.x); + x.z += (x.x * x.y); + return x; +} +float rand_pcg(float p) +{ + return (float(pcg(floatBitsToUint(p))) / float(0xffffffffu)); +} +float rand_pcg(vec2 p) +{ + return (float(pcg((pcg(floatBitsToUint(p.x)) + floatBitsToUint(p.y)))) / float(0xffffffffu)); +} +vec3 rand_pcg(vec3 p) +{ + return (vec3(pcg(uvec3(floatBitsToUint(p.x), floatBitsToUint(p.y), floatBitsToUint(p.z)))) / float(0xffffffffu)); +} +float rand(vec2 s) +{ + return rand_pcg((gl_FragCoord.xy + (s * 100.) + (time * vec2(-5.79152364633046090603, 9.7885538067203015089)))); +} +float smoothstair(float x, float steps, float steepness) +{ + x *= steps; + float c = ((2. / (1. - steepness)) - 1.); + float p = mod(x, 1.); + return ((floor(x) + ((p < 0.5) ? (pow(p, c) / pow(0.5, (c - 1.))) : (1. - (pow((1. - p), c) / pow(0.5, (c - 1.)))))) / steps); +} +BoxIntersection find_box_intersection(Ray ray, vec3 pos, vec3 size) +{ + vec3 m = (1. / ray.dir); + vec3 n = (m * (ray.pos - pos)); + vec3 k = (abs(m) * size); + vec3 t1 = (0. - (n + k)); + vec3 t2 = (k - n); + float tN = max(max(t1.x, t1.y), t1.z); + float tF = min(min(t2.x, t2.y), t2.z); + if (((tN > tF) || (tF < 0.))) { + return(BoxIntersection(false, 0., 0., vec3(0.))); + } + return BoxIntersection(true, tN, tF, (0. - (sign(ray.dir) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz)))); +} +vec2 cis(float angle) +{ + return vec2(cos(angle), sin(angle)); +} +vec2 field(vec3 pos) +{ + ivec2 hash_pos = ivec2(((0.5 * (1. + pos.xy)) * 512.)); + uvec4 hash_data = texelFetch(hash_tex, hash_pos, 0); + vec3 circle = vec3(((2. * (vec2(hash_data.xy) * 0.00000000023283064371)) - 1.), (float(hash_data.z) * 0.00000000023283064371)); + float circle_phase = (float((hash_data.w % 65535u)) * 0.00001525902189669642); + float steepness = (pow(4., (float((hash_data.w / 65535u)) * 0.00001525902189669642)) * 0.2000000000000000111); + { + vec3 local_pos = ((pos - vec3((circle.xy + (0.02999999999999999889 * cis((6.283185307179586232 * smoothstair((circle_phase + (time * 0.2000000000000000111)), 4., steepness))))), 0.)) / circle.z); + if (((hash_data.w % 2u) == 0u)) { + local_pos = local_pos.yzx; + } + if ((((hash_data.w / 64u) % 2u) == 0u)) { + local_pos = local_pos.zxy; + } + local_pos += (distortion_push_dir_1 * sympow(sin((6.283185307179586232 * ((0.66000000000000003109 * dot(local_pos, distortion_dot_dir_1) * pow(1.25, sin((time * 2.81700000000000017053)))) + (0.33000000000000001554 * time) + circle_phase))), distortion_power_1) * 0.125); + local_pos += (distortion_push_dir_2 * sympow(sin(((6. * dot(local_pos, distortion_dot_dir_2) * pow(1.60000000000000008882, sin((time * 0.5)))) + (2.5 * time) + (6.283185307179586232 * 1.69999999999999995559 * circle_phase))), distortion_power_2) * 0.25); + if ((length(local_pos) < 1.)) { + return(vec2(1., 0.5999999999999999778)); + } + } + return vec2(0.); +} +void main() +{ + float resolution_max = max(resolution.x, resolution.y); + vec2 pixel_pos = ((((gl_FragCoord.xy / resolution_max) - (0.5 * ((resolution - resolution_max) / resolution_max))) * 2.) - 1.); + float accumulated_light = 0.; + Ray base_ray = Ray(vec3(0., 0., -1.25), normalize(vec3((0.75 * pixel_pos), 1.))); + BoxIntersection bound_intersection = find_box_intersection(base_ray, vec3(0.), vec3(1., 1., 0.1749999999999999889)); + if (bound_intersection.hit) { + base_ray = progress_ray(base_ray, bound_intersection.front_dist); + for (int i = 0; i < 1; i++) { + vec3 pos = base_ray.pos; + vec3 dir = base_ray.dir; + float t = 1.; + bool has_been_insideQUESTION_MARK = false; + for (int j = 0; j < 22; j++) { + pos += (-log(rand(vec2(i, j))) * 0.01750000000000000167 * dir); + if (((abs(pos.x) <= 1.) && (abs(pos.y) <= 1.) && (abs(pos.z) <= 0.1749999999999999889))) { + has_been_insideQUESTION_MARK = true; + vec2 field_sample = field(pos); + if ((field_sample.x > rand((vec2(i, j) + vec2(-0.77000000000000001776, 1.30000000000000004441))))) { + t *= field_sample.y; + dir = normalize(((2. * vec3(rand((vec2(i, j) + vec2(-0.16000000000000000333, 0.93000000000000004885))), rand((vec2(i, j) + vec2(0.2000000000000000111, 3.37000000000000010658))), rand((vec2(i, j) + vec2(-0.51200000000000001066, 2.31000000000000005329))))) - 1.)); + } + } + else { + if (has_been_insideQUESTION_MARK) { + accumulated_light += (t * smoothstep(0.5, 1., dot(dir, vec3(0., 0.2425356250363329691, -0.97014250014533187638)))); + break; + } + } + } + } + } + frag_color = uvec4(mix((accumulated_light * 34359738360.), float(texelFetch(old_tex, ivec2(gl_FragCoord.xy), 0).x), 0.96999999999999997335), 0u, 0u, 0u); +} +`, + [ + 'resolution', + 'old_tex', + 'hash_tex', + 'distortion_dot_dir_1', + 'distortion_push_dir_1', + 'distortion_dot_dir_2', + 'distortion_push_dir_2', + 'distortion_power_1', + 'distortion_power_2', + 'time', + ], + ); + const hash_shader = create_shader( + vert, + `#version 300 es +precision highp float; +precision highp usampler2D; +precision highp int; +uniform vec3[${circles.length}] circles; +out uvec4 frag_color; +uint pcg(uint x) +{ + uint state = ((x * 747796405u) + 2891336453u); + uint word = (((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u); + return ((word >> 22u) ^ word); +} +uvec3 pcg(uvec3 x) +{ + x = ((x * 1664525u) + 1013904223u); + x.x += (x.y * x.z); + x.y += (x.z * x.x); + x.z += (x.x * x.y); + x ^= (x >> 16u); + x.x += (x.y * x.z); + x.y += (x.z * x.x); + x.z += (x.x * x.y); + return x; +} +float rand_pcg(float p) +{ + return (float(pcg(floatBitsToUint(p))) / float(0xffffffffu)); +} +float rand_pcg(vec2 p) +{ + return (float(pcg((pcg(floatBitsToUint(p.x)) + floatBitsToUint(p.y)))) / float(0xffffffffu)); +} +vec3 rand_pcg(vec3 p) +{ + return (vec3(pcg(uvec3(floatBitsToUint(p.x), floatBitsToUint(p.y), floatBitsToUint(p.z)))) / float(0xffffffffu)); +} +void main() +{ + vec3 closest_circle; + float closest_dist = 10.; + int closest_index; + vec2 world_pos = ((2. * (gl_FragCoord.xy * 0.001953125)) - 1.); + for (int i = 0; i < ${circles.length}; i++) { + vec3 circle = circles[i]; + float d = (distance(world_pos, circle.xy) - circle.z); + if ((d < closest_dist)) { + closest_index = i; + closest_dist = d; + closest_circle = circle; + } + } + frag_color = uvec4(((0.5 * (1. + closest_circle.xy)) * 4294967295.), (closest_circle.z * 4294967295.), (uint((rand_pcg(vec2(closest_index, 0.)) * 65535.)) + (65535u * uint((rand_pcg(vec2(closest_index, 21.71000000000000085265)) * 65535.))))); +} +`, + ['circles'], + ); + framebuffer = gl.createFramebuffer(); + hash_tex = create_tex(512, 512); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, hash_tex, 0); + gl.useProgram(hash_shader); + gl.uniform3fv(hash_shader.circles, circles.flat()); + gl.viewport(0, 0, 512, 512); + gl.drawArrays(gl.TRIANGLES, 0, 3); + if (!webGLRenderer) { + webGLRenderer = new WebGLRenderer(renderTask); + webGLRenderer.start(); + } + return null; +}; +const kill = () => { + if (webGLRenderer) { + webGLRenderer.stop(); + webGLRenderer = null; + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); + canvas = null; + gl = null; + texs = null; + hash_tex = null; + framebuffer = null; + render_shader = null; + trace_shader = null; + return null; +}; +const renderTask = () => { + if (canvas) { + const width = canvas.width; + const height = canvas.height; + gl.viewport(0, 0, width, height); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.drawBuffers([gl.COLOR_ATTACHMENT0]); + if (width !== last_resolution[0] || height !== last_resolution[1]) { + last_resolution = [width, height]; + const old_texs = texs; + texs = [create_tex(width, height), create_tex(width, height)]; + if (old_texs) { + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texs[0], 0); + gl.useProgram(rescale_shader); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, old_texs[0]); + gl.uniform1i(rescale_shader.tex, 0); + gl.uniform2fv(rescale_shader.resolution, [width, height]); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.deleteTexture(old_texs[0]); + gl.deleteTexture(old_texs[1]); + } + } + if (!texs) texs = [create_tex(width, height), create_tex(width, height)]; + + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texs[1], 0); + gl.useProgram(trace_shader); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texs[0]); + gl.activeTexture(gl.TEXTURE0 + 1); + gl.bindTexture(gl.TEXTURE_2D, hash_tex); + gl.uniform1i(trace_shader.old_tex, 0); + gl.uniform1i(trace_shader.hash_tex, 1); + gl.uniform2fv(trace_shader.resolution, [width, height]); + gl.uniform1fv(trace_shader.time, [0.0006 * window.performance.now()]); + gl.uniform3fv(trace_shader.distortion_dot_dir_1, distortion_dot_dir_1); + gl.uniform3fv(trace_shader.distortion_push_dir_1, distortion_push_dir_1); + gl.uniform3fv(trace_shader.distortion_dot_dir_2, distortion_dot_dir_2); + gl.uniform3fv(trace_shader.distortion_push_dir_2, distortion_push_dir_2); + gl.uniform1fv(trace_shader.distortion_power_1, [distortion_power_1]); + gl.uniform1fv(trace_shader.distortion_power_2, [distortion_power_2]); + gl.drawArrays(gl.TRIANGLES, 0, 3); + texs.reverse(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.useProgram(render_shader); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texs[0]); + gl.uniform1i(render_shader.tex, 0); + gl.uniform2fv(render_shader.resolution, [width, height]); + gl.uniform3fv(render_shader.color, cell_color); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + return null; +}; + +const set_color = (new_color) => { + cell_color = new_color; +}; + +export const start_cells = init; +export const stop_cells = kill; +export const set_cell_color = set_color; diff --git a/frontend/src/modules/common/bg-animation/index.tsx b/frontend/src/modules/common/bg-animation/index.tsx new file mode 100644 index 000000000..0062c2a47 --- /dev/null +++ b/frontend/src/modules/common/bg-animation/index.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useThemeStore } from '~/store/theme.js'; +import { set_cell_color, start_cells, stop_cells } from './animation.js'; + +function maximize_canvas(c: HTMLCanvasElement) { + if (!c) return; + + const width = window.innerWidth; + const height = window.innerHeight; + c.width = width; + c.height = height; +} + +const BgAnimation = () => { + const { theme } = useThemeStore(); + + useEffect(() => { + const c = document.getElementById('animation-canvas') as HTMLCanvasElement; + if (!c) return; + + start_cells(c); + set_cell_color(theme === 'none' ? [0.3, 0.3, 0.3] : [0.9, 0.2, 0.2]); + maximize_canvas(c); + + return () => { + stop_cells(); + }; + }, [document]); + + return ; +}; + +export default BgAnimation; diff --git a/frontend/src/modules/common/contact-form/contact-form-map.tsx b/frontend/src/modules/common/contact-form/contact-form-map.tsx new file mode 100644 index 000000000..e6eb88725 --- /dev/null +++ b/frontend/src/modules/common/contact-form/contact-form-map.tsx @@ -0,0 +1,110 @@ +import { APIProvider, AdvancedMarker, ControlPosition, Map as GMap, InfoWindow, MapControl, useAdvancedMarkerRef } from '@vis.gl/react-google-maps'; +import { config } from 'config'; +import { Minus, Plus, X } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '~/modules/ui/button'; +import { useThemeStore } from '~/store/theme'; +import Logo from '/static/logo/logo-icon-only.svg'; + +type MapConfig = { + id: string; + label: string; + mapId?: string; + mapTypeId?: string; +}; + +const mapStyles: MapConfig[] = [ + { + id: 'light', + label: 'Light', + mapId: '49ae42fed52588c3', + mapTypeId: 'roadmap', + }, + { + id: 'dark', + label: 'Dark', + mapId: '739af084373f96fe', + mapTypeId: 'roadmap', + }, +]; + +const MarkerWithInfowindow = ({ position }: { position: { lat: number; lng: number } }) => { + const { t } = useTranslation(); + const [markerRef, marker] = useAdvancedMarkerRef(); + const [infowindowOpen, setInfowindowOpen] = useState(true); + + return ( + <> + setInfowindowOpen(true)} position={position} title="More info"> + {config.name} + + + {infowindowOpen && ( + +
    +
    + {config.company.name} + +
    + {config.company.streetAddress} + {config.company.country} + + {t('common:get_directions')} + +
    +
    + )} + + ); +}; + +type CustomZoomControlProps = { + controlPosition: ControlPosition; + zoom: number; + onZoomChange: (zoom: number) => void; +}; + +const CustomZoomControl = ({ controlPosition, zoom, onZoomChange }: CustomZoomControlProps) => { + return ( + +
    + + +
    +
    + ); +}; + +const ContactFormMap = () => { + const { mode } = useThemeStore(); + const [zoom, setZoom] = useState(config.company.mapZoom); + const [mapConfig] = useState(mode === 'dark' ? mapStyles[1] : mapStyles[0]); + + if (config.company.coordinates && config.googleMapsKey) + return ( +
    + + + + setZoom(zoom)} /> + + +
    + ); +}; +export default ContactFormMap; diff --git a/frontend/src/modules/common/contact-form/contact-form.tsx b/frontend/src/modules/common/contact-form/contact-form.tsx new file mode 100644 index 000000000..2f6a2f18b --- /dev/null +++ b/frontend/src/modules/common/contact-form/contact-form.tsx @@ -0,0 +1,111 @@ +import { zodResolver } from '@hookform/resolvers/zod'; + +import { Mail, MessageSquare, Send, User } from 'lucide-react'; +import type { SubmitHandler } from 'react-hook-form'; +import { toast } from 'sonner'; +import * as z from 'zod'; +import { isDialog as checkDialog, dialog } from '~/modules/common/dialoger/state'; + +import { Suspense, lazy, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createRequest as baseCreateRequest } from '~/api/requests'; +import { useBreakpoints } from '~/hooks/use-breakpoints'; +import { useFormWithDraft } from '~/hooks/use-draft-form'; +import { useMutation } from '~/hooks/use-mutations'; +import UnsavedBadge from '~/modules/common/unsaved-badge'; +import { Button } from '~/modules/ui/button'; +import { Form } from '~/modules/ui/form'; +import { useUserStore } from '~/store/user'; +import InputFormField from '../form-fields/input'; + +const ContactFormMap = lazy(() => import('./contact-form-map')); + +// Main contact form map component +const ContactForm = ({ dialog: isDialog }: { dialog?: boolean }) => { + const isMediumScreen = useBreakpoints('min', 'md'); + const { user } = useUserStore(({ user }) => ({ user })); + const { t } = useTranslation(); + + const formSchema = z.object({ + name: z.string().min(2, t('common:error.name_required')).default(''), + email: z.string().email(t('common:error.invalid_email')).default(''), + message: z.string().default(''), + }); + + type FormValues = z.infer; + + const form = useFormWithDraft('contact-form', { + resolver: zodResolver(formSchema), + defaultValues: { name: user?.name || '', email: user?.email || '', message: '' }, + }); + + const cancel = () => { + form.reset(); + isDialog && dialog.remove(); + }; + + const { mutate: createRequest } = useMutation({ + mutationFn: baseCreateRequest, + onSuccess: () => { + toast.success(t('common:message_sent.text')); + if (isDialog) dialog.remove(); + form.reset(); + }, + onError: () => { + toast.error(t('common:error.reported_try_later')); + }, + }); + + const onSubmit: SubmitHandler = async (data) => { + const { name, email, message } = data; + createRequest({ email, type: 'CONTACT_REQUEST', message: `${name} with the message: ${message}` }); + }; + + // Update dialog title with unsaved changes + useEffect(() => { + if (form.unsavedChanges) { + const targetDialog = dialog.get('contact-form'); + if (targetDialog && checkDialog(targetDialog)) { + dialog.update('contact-form', { + title: , + }); + } + return; + } + dialog.reset('contact-form'); + }, [form.unsavedChanges]); + + return ( +
    +
    +
    +
    + + } required /> + } required /> + } /> +
    + + +
    + + +
    +
    + {isMediumScreen && ( + +
    + +
    +
    + )} +
    + ); +}; + +export default ContactForm; diff --git a/frontend/src/modules/common/content-placeholder.tsx b/frontend/src/modules/common/content-placeholder.tsx new file mode 100644 index 000000000..f6753092f --- /dev/null +++ b/frontend/src/modules/common/content-placeholder.tsx @@ -0,0 +1,22 @@ +import type { LucideProps } from 'lucide-react'; +import type React from 'react'; +import { cn } from '~/lib/utils'; + +interface Props { + title: string; + Icon?: React.ElementType; + text?: string | React.ReactNode; + className?: string; +} + +const ContentPlaceholder = ({ title, Icon, text, className = '' }: Props) => { + return ( +
    + {Icon && } +

    {title}

    + {text &&
    {text}
    } +
    + ); +}; + +export default ContentPlaceholder; diff --git a/frontend/src/modules/common/country-flag.tsx b/frontend/src/modules/common/country-flag.tsx new file mode 100644 index 000000000..59ed54581 --- /dev/null +++ b/frontend/src/modules/common/country-flag.tsx @@ -0,0 +1,29 @@ +import type React from 'react'; + +interface ImgProps extends React.ImgHTMLAttributes { + countryCode: string; + className?: string; + imgType?: 'svg' | 'png'; +} + +export const CountryFlag = ({ countryCode, className, imgType = 'svg', width = 16, height = 12, ...props }: ImgProps) => { + if (typeof countryCode !== 'string') return null; + if (countryCode.toLowerCase() === 'en') countryCode = 'gb'; + + const flagUrl = imgType === 'svg' ? `/static/flags/${countryCode.toLowerCase()}.svg` : `/static/flags/png/${countryCode.toLowerCase()}.png`; + + return ( + {`Flag + ); +}; + +export default CountryFlag; diff --git a/frontend/src/modules/common/data-table/checkbox-column.tsx b/frontend/src/modules/common/data-table/checkbox-column.tsx new file mode 100644 index 000000000..46a9d556a --- /dev/null +++ b/frontend/src/modules/common/data-table/checkbox-column.tsx @@ -0,0 +1,15 @@ +import { type Column, SelectColumn } from 'react-data-grid'; + +// biome-ignore lint/suspicious/noExplicitAny: any is used for compatibility with react-data-grid +const CheckboxColumn: Column & { + visible: boolean; +} = { + ...SelectColumn, + key: 'checkbox-column', + frozen: false, + headerCellClass: 'flex items-center justify-center', + cellClass: 'flex items-center justify-center', + visible: true, +}; + +export default CheckboxColumn; diff --git a/frontend/src/modules/common/data-table/columns-view.tsx b/frontend/src/modules/common/data-table/columns-view.tsx new file mode 100644 index 000000000..3c18b234a --- /dev/null +++ b/frontend/src/modules/common/data-table/columns-view.tsx @@ -0,0 +1,81 @@ +import { SlidersHorizontal } from 'lucide-react'; +import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'; +import type { ColumnOrColumnGroup as BaseColumnOrColumnGroup } from 'react-data-grid'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '~/modules/ui/badge'; +import { Button } from '~/modules/ui/button'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; +import { TooltipButton } from '../tooltip-button'; + +export type ColumnOrColumnGroup = BaseColumnOrColumnGroup & { + key: string; + visible?: boolean; +}; + +interface Props { + columns: ColumnOrColumnGroup[]; + setColumns: Dispatch[]>>; + className?: string; +} + +const ColumnsView = ({ columns, setColumns, className = '' }: Props) => { + const { t } = useTranslation(); + const [columnSearch, setColumnSearch] = useState(''); + + const filteredColumns = useMemo( + () => + columns.filter( + (column) => typeof column.name === 'string' && column.name && column.name.toLocaleLowerCase().includes(columnSearch.toLocaleLowerCase()), + ), + [columns, columnSearch], + ); + + const height = useMemo(() => (filteredColumns.length > 5 ? 6 * 32 - 16 + 4 : filteredColumns.length * 32 + 8), [filteredColumns.length]); + + return ( + { + setColumnSearch(''); + }} + > + + + + + + +
    + {filteredColumns.map((column) => ( + + setColumns((columns) => + columns.map((c) => + c.name === column.name + ? { + ...c, + visible: !c.visible, + } + : c, + ), + ) + } + onSelect={(e) => e.preventDefault()} + > + {column.name} + + ))} +
    +
    + + + ); +}; + +export default ColumnsView; diff --git a/frontend/src/modules/common/data-table/export.tsx b/frontend/src/modules/common/data-table/export.tsx new file mode 100644 index 000000000..f1ebdbb5d --- /dev/null +++ b/frontend/src/modules/common/data-table/export.tsx @@ -0,0 +1,76 @@ +import { Download } from 'lucide-react'; +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { exportToCsv, exportToPdf } from '~/lib/export'; +import router from '~/lib/router'; +import { Button } from '~/modules/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; +import { type Theme, useThemeStore } from '~/store/theme'; +import { TooltipButton } from '../tooltip-button'; + +interface Props { + filename: string; + columns: { key: string; name: ReactElement | string }[]; + selectedRows: R[]; + fetchRows: (limit: number) => Promise; + className?: string; +} + +const Export = ({ filename, columns, selectedRows, fetchRows, className = '' }: Props) => { + const { t } = useTranslation(); + + const onExport = async (type: 'csv' | 'pdf', selected: boolean) => { + const rows = selected ? selectedRows : await fetchRows(1000); + const filenameWithExtension = `${filename}.${type}`; + const themeState = useThemeStore.getState(); + const theme: Theme = themeState.mode; + + if (type === 'csv') return exportToCsv(columns, rows, filenameWithExtension); + + return exportToPdf(columns, rows, filenameWithExtension, router.state.location.pathname, theme); + }; + + return ( + + + + + + + + onExport('csv', false)}> + CSV + {t('common:max_1k_rows')} + + onExport('pdf', false)}> + PDF + {t('common:max_1k_rows')} + + onExport('csv', true)} disabled={selectedRows.length === 0}> + CSV + + {selectedRows.length ? `${selectedRows.length} ${t('common:selected').toLowerCase()}` : t('common:no_selection').toLowerCase()} + + + onExport('pdf', true)} disabled={selectedRows.length === 0}> + PDF + + {selectedRows.length ? `${selectedRows.length} ${t('common:selected').toLowerCase()}` : t('common:no_selection').toLowerCase()} + + + + + ); +}; + +export default Export; diff --git a/frontend/src/modules/common/data-table/header-cell.tsx b/frontend/src/modules/common/data-table/header-cell.tsx new file mode 100644 index 000000000..9ee987327 --- /dev/null +++ b/frontend/src/modules/common/data-table/header-cell.tsx @@ -0,0 +1,23 @@ +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'; +import type { RenderHeaderCellProps } from 'react-data-grid'; + +const HeaderCell = ({ column, sortDirection }: RenderHeaderCellProps) => { + if (!column.sortable) { + return
    {column.name}
    ; + } + + return ( +
    + {column.name} + {sortDirection === 'DESC' ? ( + + ) : sortDirection === 'ASC' ? ( + + ) : ( + + )} +
    + ); +}; + +export default HeaderCell; diff --git a/frontend/src/modules/common/data-table/index.tsx b/frontend/src/modules/common/data-table/index.tsx new file mode 100644 index 000000000..5a3adf967 --- /dev/null +++ b/frontend/src/modules/common/data-table/index.tsx @@ -0,0 +1,204 @@ +import 'react-data-grid/lib/styles.css'; + +import { Search } from 'lucide-react'; +import { type Key, type ReactNode, useEffect, useState } from 'react'; +import DataGrid, { type RenderRowProps, type CellClickArgs, type CellMouseEvent, type RowsChangeData, type SortColumn } from 'react-data-grid'; +import { useTranslation } from 'react-i18next'; + +import { useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { Checkbox } from '~/modules/ui/checkbox'; +import type { ColumnOrColumnGroup } from './columns-view'; +import './style.css'; +import ContentPlaceholder from '../content-placeholder'; +import { DataTableSkeleton } from './table-skeleton'; +import Spinner from '../spinner'; +import { Button } from '~/modules/ui/button'; + +interface DataTableProps { + columns: ColumnOrColumnGroup[]; + rows: TData[]; + totalCount?: number; + rowKeyGetter: (row: TData) => string; + error?: Error | null; + isLoading?: boolean; + isFetching?: boolean; + limit: number; + isFiltered?: boolean; + renderRow?: (key: Key, props: RenderRowProps) => ReactNode; + NoRowsComponent?: React.ReactNode; + overflowNoRows?: boolean; + onCellClick?: (args: CellClickArgs, event: CellMouseEvent) => void; + selectedRows?: Set; + onSelectedRowsChange?: (selectedRows: Set) => void; + sortColumns?: SortColumn[]; + onSortColumnsChange?: (sortColumns: SortColumn[]) => void; + rowHeight?: number; + enableVirtualization?: boolean; + onRowsChange?: (rows: TData[], data: RowsChangeData) => void; + fetchMore?: () => Promise; +} + +const NoRows = ({ + isFiltered, + isFetching, + customComponent, +}: { + isFiltered?: boolean; + isFetching?: boolean; + customComponent?: React.ReactNode; +}) => { + const { t } = useTranslation(); + + return ( +
    + {isFiltered && !isFetching && ( + + )} + {!isFiltered && !isFetching && (customComponent ?? t('common:no_resource_yet', { resource: t('common:results').toLowerCase() }))} +
    + ); +}; + +const ErrorMessage = ({ + error, +}: { + error: Error; +}) => { + return ( +
    +
    {error.message}
    +
    + ); +}; + +export const DataTable = ({ + columns, + rows, + totalCount, + rowKeyGetter, + error, + isLoading, + limit, + isFetching, + NoRowsComponent, + isFiltered, + selectedRows, + onSelectedRowsChange, + sortColumns, + onSortColumnsChange, + rowHeight = 40, + enableVirtualization, + onRowsChange, + fetchMore, + renderRow, + onCellClick, +}: DataTableProps) => { + const { t } = useTranslation(); + const [initialDone, setInitialDone] = useState(false); + const { ref: measureRef, inView } = useInView({ + triggerOnce: false, + threshold: 0, + }); + + useEffect(() => { + if (!rows.length || error) return; + + if (inView && !isFetching) { + if (typeof totalCount === 'number' && rows.length >= totalCount) { + return; + } + fetchMore?.(); + } + }, [inView, error, fetchMore]); + + useEffect(() => { + if (initialDone) return; + if (!isLoading) setInitialDone(true); + }, [isLoading]); + + return ( +
    + {initialDone ? ( // Render skeleton only on initial load + <> + {error && rows.length === 0 ? ( + + ) : !rows.length ? ( + + ) : ( +
    + { + const withShift = useRef(false); + + const handleChange = (checked: boolean) => { + onChange(checked, withShift.current); + }; + + return ( + { + withShift.current = e.nativeEvent.shiftKey; + }} + onCheckedChange={(checked) => { + handleChange(!!checked); + }} + /> + ); + }, + }} + /> + + {/* Infinite loading measure ref */} +
    + + {/* Loading */} + {isFetching && !error && ( +
    + +
    + )} + + {/* Infinite scroll is stuck */} + {!isFetching && !error && totalCount && totalCount > rows.length && ( + + )} + + {/* Error */} + {error &&
    {t('common:error.load_more_failed')}
    } +
    + )} + + ) : ( + + )} +
    + ); +}; diff --git a/frontend/src/modules/common/data-table/init-sort-columns.ts b/frontend/src/modules/common/data-table/init-sort-columns.ts new file mode 100644 index 000000000..076fcaf88 --- /dev/null +++ b/frontend/src/modules/common/data-table/init-sort-columns.ts @@ -0,0 +1,9 @@ +import type { SortColumn } from 'react-data-grid'; + +// Initial sort of columns for tables +// biome-ignore lint/suspicious/noExplicitAny: +export const getInitialSortColumns = (search: any): SortColumn[] => { + return search.sort && search.order + ? [{ columnKey: search.sort, direction: search.order === 'asc' ? 'ASC' : 'DESC' }] + : [{ columnKey: 'createdAt', direction: 'DESC' }]; +}; diff --git a/frontend/src/modules/common/data-table/select-column.tsx b/frontend/src/modules/common/data-table/select-column.tsx new file mode 100644 index 000000000..fda08ecc9 --- /dev/null +++ b/frontend/src/modules/common/data-table/select-column.tsx @@ -0,0 +1,38 @@ +import { SelectTrigger } from '@radix-ui/react-select'; +import type { config } from 'config'; +import { useTranslation } from 'react-i18next'; +import { Select, SelectContent, SelectItem, SelectValue } from '~/modules/ui/select'; + +import type { User, Member } from '~/types'; + +export const renderSelect = ({ + row, + options, + onRowChange, +}: { + row: TRow; + onRowChange: (row: TRow, commitChanges?: boolean) => void; + options: typeof config.rolesByType.entityRoles | typeof config.rolesByType.systemRoles; +}) => { + const { t } = useTranslation(); + const onChooseValue = (value: string) => { + if ('membership' in row) return onRowChange({ ...row, membership: { ...row.membership, role: value } }, true); + + onRowChange({ ...row, role: value }, true); + }; + const role = 'membership' in row && row.membership ? row.membership.role : row.role; + return ( + + ); +}; diff --git a/frontend/src/modules/common/data-table/style.css b/frontend/src/modules/common/data-table/style.css new file mode 100644 index 000000000..bc308d789 --- /dev/null +++ b/frontend/src/modules/common/data-table/style.css @@ -0,0 +1,32 @@ +.grid.rdg-wrapper > .rdg { + --rdg-color: hsl(var(--foreground)); + --rdg-border-color: hsl(var(--border)); + --rdg-summary-border-color: hsl(var(--border)); + --rdg-background-color: hsl(var(--background)); + --rdg-header-background-color: hsl(var(--background)); + --rdg-header-draggable-background-color: hsl(var(--accent)); + --rdg-row-hover-background-color: hsl(var(--accent)); + --rdg-row-selected-background-color: hsl(var(--accent)); + --rdg-row-selected-hover-background-color: hsl(var(--muted)); + --rdg-checkbox-color: hsl(var(--muted-foreground)); + --rdg-checkbox-focus-color: hsl(var(--ring)); + --rdg-checkbox-disabled-border-color: hsl(var(--muted-foreground)); + --rdg-checkbox-disabled-background-color: hsl(var(--muted)); + + --rdg-selection-color: hsl(var(--ring)); + + grid-template-rows: none !important; +} + +.rdg-cell { + display: flex; + align-items: center; +} + +.rdg-cell:not([role="columnheader"]) { + border-top: .07rem solid var(--rdg-border-color); +} + +.rdg-cell.rdg-expand-cell { + border-top-color: transparent; +} diff --git a/frontend/src/modules/common/data-table/table-count.tsx b/frontend/src/modules/common/data-table/table-count.tsx new file mode 100644 index 000000000..b3938db3a --- /dev/null +++ b/frontend/src/modules/common/data-table/table-count.tsx @@ -0,0 +1,36 @@ +import { FilterX } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../../ui/button'; + +interface TableCountProps { + count?: number; + type: string; + isFiltered?: boolean; + onResetFilters?: () => void; +} + +const TableCount = ({ count, type, isFiltered, onResetFilters }: TableCountProps) => { + const { t } = useTranslation(); + + return ( +
    + {count !== undefined && ( + <> + {isFiltered && ( + + )} +
    + {new Intl.NumberFormat('de-DE').format(count)} {count === 1 ? t(`common:${type}`).toLowerCase() : t(`common:${type}s`).toLowerCase()} + {isFiltered && ' '} + {isFiltered && t('common:found')} +
    + + )} +
    + ); +}; + +export default TableCount; diff --git a/frontend/src/modules/common/data-table/table-filter-bar.tsx b/frontend/src/modules/common/data-table/table-filter-bar.tsx new file mode 100755 index 000000000..34d196d3e --- /dev/null +++ b/frontend/src/modules/common/data-table/table-filter-bar.tsx @@ -0,0 +1,76 @@ +import { Filter, FilterX, X } from 'lucide-react'; +import { createContext, useContext, useState } from 'react'; +import { Button } from '~/modules/ui/button'; + +import { motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; +import { cn } from '~/lib/utils'; + +interface TableFilterBarProps { + children: React.ReactNode; + isFiltered?: boolean; + onResetFilters: () => void; + id?: string; +} + +interface FilterBarChildProps { + children: React.ReactNode; + className?: string; +} + +// Create a Context with default values +export const TableFilterBarContext = createContext<{ + isFilterActive: boolean; + setFilterActive: (isActive: boolean) => void; +}>({ + isFilterActive: false, + setFilterActive: () => {}, +}); + +export const FilterBarActions = ({ children, className = '' }: FilterBarChildProps) => { + const { isFilterActive } = useContext(TableFilterBarContext); + return
    {children}
    ; +}; + +export const FilterBarContent = ({ children, className = '' }: FilterBarChildProps) => { + const { isFilterActive } = useContext(TableFilterBarContext); + return ( +
    + {children} +
    + ); +}; + +export const TableFilterBar = ({ onResetFilters, isFiltered, children, id }: TableFilterBarProps) => { + const { t } = useTranslation(); + + const [isFilterActive, setFilterActive] = useState(!!isFiltered); + + const clearFilters = () => { + if (isFiltered) return onResetFilters(); + setFilterActive(false); + }; + + return ( + <> + {children} + {!isFilterActive && ( + + )} + {isFilterActive && ( + + )} + + ); +}; diff --git a/frontend/src/modules/common/data-table/table-search.tsx b/frontend/src/modules/common/data-table/table-search.tsx new file mode 100644 index 000000000..d579b3219 --- /dev/null +++ b/frontend/src/modules/common/data-table/table-search.tsx @@ -0,0 +1,46 @@ +import { Search, XCircle } from 'lucide-react'; +import { useContext, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '~/modules/ui/input'; +import { TableFilterBarContext } from './table-filter-bar'; + +const TableSearch = ({ value = '', setQuery }: { value?: string; setQuery: (value: string) => void }) => { + const { t } = useTranslation(); + const { isFilterActive } = useContext(TableFilterBarContext); + + const inputRef = useRef(null); + + const handleClick = () => { + inputRef.current?.focus(); + }; + + // Focus input when filter button clicked(mobile) + useEffect(() => { + if (isFilterActive) inputRef.current?.focus(); + }, [isFilterActive]); + + return ( + <> +
    + + setQuery(event.target.value)} + style={{ paddingLeft: '2rem' }} + className="h-10 w-full border-0" + ref={inputRef} + /> + {!!value.length && ( + setQuery('')} + /> + )} +
    + + ); +}; + +export default TableSearch; diff --git a/frontend/src/modules/common/data-table/table-skeleton.tsx b/frontend/src/modules/common/data-table/table-skeleton.tsx new file mode 100644 index 000000000..10c9571ea --- /dev/null +++ b/frontend/src/modules/common/data-table/table-skeleton.tsx @@ -0,0 +1,63 @@ +import useMounted from '~/hooks/use-mounted'; +import { Skeleton } from '~/modules/ui/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/modules/ui/table'; + +interface DataTableSkeletonProps { + rowCount?: number; + cellHeight?: number; + columnCount?: number; + cellsWidths?: string[]; + shrinkTable?: boolean; +} + +export const DataTableSkeleton = ({ + columnCount = 4, + cellHeight = 40, + cellsWidths = [], + rowCount = 20, + shrinkTable = false, +}: DataTableSkeletonProps) => { + const renderCellHeight = cellHeight - 18; + const { hasStarted } = useMounted(); + + return ( +
    + + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + +
    +
    + ); +}; diff --git a/frontend/src/modules/common/data-table/toggle-expand.tsx b/frontend/src/modules/common/data-table/toggle-expand.tsx new file mode 100644 index 000000000..0b797301f --- /dev/null +++ b/frontend/src/modules/common/data-table/toggle-expand.tsx @@ -0,0 +1,44 @@ +export const toggleExpand = < + T extends { + id: string; + _type: string; + _expanded?: boolean; + _parent?: { id: string }; + }[], +>( + changedRows: T, + indexes: number[], +) => { + let rows = [...changedRows]; + const index = indexes[0]; + const row = rows[index]; + + if (row._type === 'MASTER') { + if (row._expanded) { + const detailId = `${row.id}-detail`; + rows.splice(index + 1, 0, { + _type: 'DETAIL', + id: detailId, + _parent: row, + }); + + // Close other masters + rows = rows.map((r) => { + if (r._type === 'MASTER' && r.id === row.id) { + return r; + } + return { + ...r, + _expanded: false, + }; + }); + + // Remove other details + rows = rows.filter((r) => r._type === 'MASTER' || r.id === detailId); + } else { + rows.splice(indexes[0] + 1, 1); + } + } + + return rows; +}; diff --git a/frontend/src/modules/common/date-time-picker/demo.tsx b/frontend/src/modules/common/date-time-picker/demo.tsx new file mode 100644 index 000000000..17c6d8081 --- /dev/null +++ b/frontend/src/modules/common/date-time-picker/demo.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import dayjs from 'dayjs'; +import { Calendar as CalendarIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { cn } from '~/lib/utils'; +import { Button } from '~/modules/ui/button'; +import { Calendar } from '~/modules/ui/calendar'; +import { FormControl, FormField, FormItem, FormLabel } from '~/modules/ui/form'; +import { Form } from '~/modules/ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover'; +import { TimePicker } from './time-picker'; + +const formSchema = z.object({ + dateTime: z.date(), +}); + +type FormSchemaType = z.infer; + +export function DateTimePickerForm() { + const form = useForm({ + resolver: zodResolver(formSchema), + }); + + function onSubmit(data: FormSchemaType) { + console.log('You submitted the following values:', JSON.stringify(data, null, 2)); + } + + return ( +
    + + ( + + DateTime + + + + + + + + +
    + +
    +
    +
    +
    + )} + /> + + + + ); +} diff --git a/frontend/src/modules/common/date-time-picker/index.tsx b/frontend/src/modules/common/date-time-picker/index.tsx new file mode 100644 index 000000000..fc41da9ea --- /dev/null +++ b/frontend/src/modules/common/date-time-picker/index.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Calendar as CalendarIcon } from 'lucide-react'; +import * as React from 'react'; + +import dayjs from 'dayjs'; +import { cn } from '~/lib/utils'; +import { Button } from '~/modules/ui/button'; +import { Calendar } from '~/modules/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover'; +import { TimePicker } from './time-picker'; + +export function DateTimePicker() { + const [date, setDate] = React.useState(); + + return ( + + + + + + +
    + +
    +
    +
    + ); +} diff --git a/frontend/src/modules/common/date-time-picker/time-picker-input.tsx b/frontend/src/modules/common/date-time-picker/time-picker-input.tsx new file mode 100644 index 000000000..43cf65e34 --- /dev/null +++ b/frontend/src/modules/common/date-time-picker/time-picker-input.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React from 'react'; +import { cn } from '~/lib/utils'; +import { Input } from '~/modules/ui/input'; +import { type TimePickerType, getArrowByType, getDateByType, setDateByType } from './time-picker-utils'; + +export interface TimePickerInputProps extends React.InputHTMLAttributes { + picker: TimePickerType; + date: Date | undefined; + setDate: (date: Date | undefined) => void; + onRightFocus?: () => void; + onLeftFocus?: () => void; +} + +const TimePickerInput = React.forwardRef( + ( + { + className, + type = 'tel', + value, + id, + name, + date = new Date(new Date().setHours(0, 0, 0, 0)), + setDate, + onChange, + onKeyDown, + picker, + onLeftFocus, + onRightFocus, + ...props + }, + ref, + ) => { + const [flag, setFlag] = React.useState(false); + + /** + * allow the user to enter the second digit within 2 seconds + * otherwise start again with entering first digit + */ + React.useEffect(() => { + if (flag) { + const timer = setTimeout(() => { + setFlag(false); + }, 2000); + + return () => clearTimeout(timer); + } + }, [flag]); + + const calculatedValue = React.useMemo(() => getDateByType(date, picker), [date, picker]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') return; + e.preventDefault(); + if (e.key === 'ArrowRight') onRightFocus?.(); + if (e.key === 'ArrowLeft') onLeftFocus?.(); + if (['ArrowUp', 'ArrowDown'].includes(e.key)) { + const step = e.key === 'ArrowUp' ? 1 : -1; + const newValue = getArrowByType(calculatedValue, step, picker); + if (flag) setFlag(false); + const tempDate = new Date(date); + setDate(setDateByType(tempDate, newValue, picker)); + } + if (e.key >= '0' && e.key <= '9') { + const newValue = !flag ? `0${e.key}` : calculatedValue.slice(1, 2) + e.key; + if (flag) onRightFocus?.(); + setFlag((prev) => !prev); + const tempDate = new Date(date); + setDate(setDateByType(tempDate, newValue, picker)); + } + }; + + return ( + { + e.preventDefault(); + onChange?.(e); + }} + type={type} + inputMode="decimal" + onKeyDown={(e) => { + onKeyDown?.(e); + handleKeyDown(e); + }} + {...props} + /> + ); + }, +); + +TimePickerInput.displayName = 'TimePickerInput'; + +export { TimePickerInput }; diff --git a/frontend/src/modules/common/date-time-picker/time-picker-utils.tsx b/frontend/src/modules/common/date-time-picker/time-picker-utils.tsx new file mode 100644 index 000000000..ffc9a7406 --- /dev/null +++ b/frontend/src/modules/common/date-time-picker/time-picker-utils.tsx @@ -0,0 +1,136 @@ +/** + * regular expression to check for valid hour format (01-23) + */ +export function isValidHour(value: string) { + return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value); +} + +/** + * regular expression to check for valid 12 hour format (01-12) + */ +export function isValid12Hour(value: string) { + return /^(0[1-9]|1[0-2])$/.test(value); +} + +/** + * regular expression to check for valid minute format (00-59) + */ +export function isValidMinuteOrSecond(value: string) { + return /^[0-5][0-9]$/.test(value); +} + +type GetValidNumberConfig = { max: number; min?: number; loop?: boolean }; + +export function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) { + let numericValue = Number.parseInt(value, 10); + + if (!Number.isNaN(numericValue)) { + if (!loop) { + if (numericValue > max) numericValue = max; + if (numericValue < min) numericValue = min; + } else { + if (numericValue > max) numericValue = min; + if (numericValue < min) numericValue = max; + } + return numericValue.toString().padStart(2, '0'); + } + + return '00'; +} + +export function getValidHour(value: string) { + if (isValidHour(value)) return value; + return getValidNumber(value, { max: 23 }); +} + +export function getValid12Hour(value: string) { + if (isValid12Hour(value)) return value; + return getValidNumber(value, { max: 12 }); +} + +export function getValidMinuteOrSecond(value: string) { + if (isValidMinuteOrSecond(value)) return value; + return getValidNumber(value, { max: 59 }); +} + +type GetValidArrowNumberConfig = { + min: number; + max: number; + step: number; +}; + +export function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) { + let numericValue = Number.parseInt(value, 10); + if (!Number.isNaN(numericValue)) { + numericValue += step; + return getValidNumber(String(numericValue), { min, max, loop: true }); + } + return '00'; +} + +export function getValidArrowHour(value: string, step: number) { + return getValidArrowNumber(value, { min: 0, max: 23, step }); +} + +export function getValidArrowMinuteOrSecond(value: string, step: number) { + return getValidArrowNumber(value, { min: 0, max: 59, step }); +} + +export function setMinutes(date: Date, value: string) { + const minutes = getValidMinuteOrSecond(value); + date.setMinutes(Number.parseInt(minutes, 10)); + return date; +} + +export function setSeconds(date: Date, value: string) { + const seconds = getValidMinuteOrSecond(value); + date.setSeconds(Number.parseInt(seconds, 10)); + return date; +} + +export function setHours(date: Date, value: string) { + const hours = getValidHour(value); + date.setHours(Number.parseInt(hours, 10)); + return date; +} + +export type TimePickerType = 'minutes' | 'seconds' | 'hours'; // | "12hours"; + +export function setDateByType(date: Date, value: string, type: TimePickerType) { + switch (type) { + case 'minutes': + return setMinutes(date, value); + case 'seconds': + return setSeconds(date, value); + case 'hours': + return setHours(date, value); + default: + return date; + } +} + +export function getDateByType(date: Date, type: TimePickerType) { + switch (type) { + case 'minutes': + return getValidMinuteOrSecond(String(date.getMinutes())); + case 'seconds': + return getValidMinuteOrSecond(String(date.getSeconds())); + case 'hours': + return getValidHour(String(date.getHours())); + default: + return '00'; + } +} + +export function getArrowByType(value: string, step: number, type: TimePickerType) { + switch (type) { + case 'minutes': + return getValidArrowMinuteOrSecond(value, step); + case 'seconds': + return getValidArrowMinuteOrSecond(value, step); + case 'hours': + return getValidArrowHour(value, step); + default: + return '00'; + } +} diff --git a/frontend/src/modules/common/date-time-picker/time-picker.tsx b/frontend/src/modules/common/date-time-picker/time-picker.tsx new file mode 100644 index 000000000..611ee36f0 --- /dev/null +++ b/frontend/src/modules/common/date-time-picker/time-picker.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { Clock } from 'lucide-react'; +import * as React from 'react'; +import { Label } from '~/modules/ui/label'; +import { TimePickerInput } from './time-picker-input'; + +interface TimePickerProps { + date: Date | undefined; + setDate: (date: Date | undefined) => void; +} + +export function TimePicker({ date, setDate }: TimePickerProps) { + const minuteRef = React.useRef(null); + const hourRef = React.useRef(null); + const secondRef = React.useRef(null); + + return ( +
    +
    + + minuteRef.current?.focus()} /> +
    +
    + + hourRef.current?.focus()} + onRightFocus={() => secondRef.current?.focus()} + /> +
    +
    + + minuteRef.current?.focus()} /> +
    +
    + +
    +
    + ); +} diff --git a/frontend/src/modules/common/debug-toolbars/index.tsx b/frontend/src/modules/common/debug-toolbars/index.tsx new file mode 100644 index 000000000..372870f6f --- /dev/null +++ b/frontend/src/modules/common/debug-toolbars/index.tsx @@ -0,0 +1,63 @@ +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; +import { Button } from '../../ui/button'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { TanStackRouterDevtools } from '@tanstack/router-devtools'; +import useMounted from '~/hooks/use-mounted'; +import './style.css'; +import { queryClient } from '~/lib/router'; + +interface DebugItem { + id: string; + icon: string; + parent: string; + element: string; +} + +const debugOptions: DebugItem[] = [ + { id: 'electric-sql', icon: '⚡', parent: '#__electric_debug_toolbar_container', element: '.rt-reset.rt-BaseButton' }, + { id: 'tanstack-router', icon: '🌴', parent: '.TanStackRouterDevtools', element: ':scope > button' }, + { id: 'react-query', icon: '📡', parent: '.tsqd-parent-container', element: '.tsqd-open-btn' }, +]; + +const DebugToolbars = () => { + const { hasStarted } = useMounted(); + const debugToggle = (item: DebugItem) => { + const parent = document.querySelector(item.parent); + if (!parent) return; + let htmlElement: HTMLButtonElement | null | undefined = parent.querySelector(item.element); + if (item.id === 'electric-sql') htmlElement = parent.shadowRoot?.querySelector(item.element); + if (!htmlElement) return; + + if (item.id === 'electric-sql') parent.classList.remove('hidden'); + htmlElement.click(); + }; + + return ( + <> + + +
    + + + + + + {debugOptions.map((item) => ( + debugToggle(item)}> + {item.icon} + {item.id} + + ))} + + +
    + + ); +}; + +export default DebugToolbars; diff --git a/frontend/src/modules/common/debug-toolbars/style.css b/frontend/src/modules/common/debug-toolbars/style.css new file mode 100644 index 000000000..9184295fc --- /dev/null +++ b/frontend/src/modules/common/debug-toolbars/style.css @@ -0,0 +1,4 @@ +.tsqd-open-btn-container, +.TanStackRouterDevtools > button { + display: none !important; +} \ No newline at end of file diff --git a/frontend/src/modules/common/delete-form.tsx b/frontend/src/modules/common/delete-form.tsx new file mode 100644 index 000000000..2d68ef356 --- /dev/null +++ b/frontend/src/modules/common/delete-form.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '~/modules/ui/button'; + +interface DeleteFormProps { + onDelete: () => void; + onCancel: () => void; + pending: boolean; +} + +export const DeleteForm = ({ onDelete, onCancel, pending }: DeleteFormProps) => { + const { t } = useTranslation(); + + return ( +
    + + +
    + ); +}; diff --git a/frontend/src/modules/common/dialoger/index.tsx b/frontend/src/modules/common/dialoger/index.tsx new file mode 100644 index 000000000..8dd448dba --- /dev/null +++ b/frontend/src/modules/common/dialoger/index.tsx @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBreakpoints } from '~/hooks/use-breakpoints'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '~/modules/ui/dialog'; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; +import { DialogState, type DialogT, type DialogToRemove, type DialogToReset } from './state'; + +export function Dialoger() { + const [dialogs, setDialogs] = useState([]); + const [updatedDialogs, setUpdatedDialogs] = useState([]); + const isMobile = useBreakpoints('max', 'sm'); + const prevFocusedElement = useRef(null); + + const onOpenChange = (dialog: DialogT) => (open: boolean) => { + if (!open) removeDialog(dialog); + }; + + const removeDialog = useCallback((dialog: DialogT | DialogToRemove) => { + setDialogs((dialogs) => dialogs.filter(({ id }) => id !== dialog.id)); + if (dialog.refocus && prevFocusedElement.current) { + // Timeout is needed to prevent focus from being stolen by the dialog that was just removed + setTimeout(() => { + prevFocusedElement.current?.focus(); + prevFocusedElement.current = null; + }, 1); + } + }, []); + + useEffect(() => { + return DialogState.subscribe((dialog) => { + if ((dialog as DialogToRemove).remove) { + removeDialog(dialog as DialogT); + return; + } + if ((dialog as DialogToReset).reset) { + setUpdatedDialogs((updatedDialogs) => updatedDialogs.filter(({ id }) => id !== dialog.id)); + return; + } + prevFocusedElement.current = (document.activeElement || document.body) as HTMLElement; + setUpdatedDialogs((updatedDialogs) => { + const existingDialog = updatedDialogs.find(({ id }) => id === dialog.id); + if (existingDialog) { + return updatedDialogs.map((d) => (d.id === dialog.id ? dialog : d)); + } + return [...updatedDialogs, dialog]; + }); + setDialogs((dialogs) => { + const existingDialog = dialogs.find(({ id }) => id === dialog.id); + if (existingDialog) { + return dialogs; + } + return [...dialogs, dialog]; + }); + }); + }, []); + + if (!dialogs.length) { + return null; + } + + return dialogs.map((dialog) => { + const existingDialog = updatedDialogs.find(({ id }) => id === dialog.id); + + if (!isMobile || !dialog.drawerOnMobile) { + return ( + + {dialog.container && ( +
    + )} + { + if (!dialog.autoFocus) event.preventDefault(); + }} + className={existingDialog?.className ? existingDialog.className : dialog.className} + container={existingDialog?.container ? existingDialog.container : dialog.container} + > + {(dialog.text || dialog.title) && ( + + + {existingDialog?.title + ? existingDialog.title + : dialog.title && (typeof dialog.title === 'string' ? {dialog.title} : dialog.title)} + + {dialog.text && {dialog.text}} + + )} + {/* For accessibility */} + {!dialog.text && !dialog.title && ( + + )} + {existingDialog?.content ? existingDialog.content : dialog.content} + +
    + ); + } + + return ( + + + {dialog.title || dialog.text ? ( + + {existingDialog?.title ? ( + existingDialog.title + ) : dialog.title ? ( + {typeof dialog.title === 'string' ? {dialog.title} : dialog.title} + ) : null} + {dialog.text && {dialog.text}} + + ) : null} +
    {dialog.content}
    +
    +
    + ); + }); +} diff --git a/frontend/src/modules/common/dialoger/state.ts b/frontend/src/modules/common/dialoger/state.ts new file mode 100644 index 000000000..6d3e0fa84 --- /dev/null +++ b/frontend/src/modules/common/dialoger/state.ts @@ -0,0 +1,142 @@ +import type React from 'react'; + +let dialogsCounter = 1; + +export type DialogT = { + id: number | string; + title?: string | React.ReactNode; + text?: React.ReactNode; + drawerOnMobile?: boolean; + container?: HTMLElement | null; + className?: string; + refocus?: boolean; + autoFocus?: boolean; + hideClose?: boolean; + content?: React.ReactNode; + titleContent?: string | React.ReactNode; + addToTitle?: boolean; + useDefaultTitle?: boolean; +}; + +export type DialogToRemove = { + id: number | string; + remove: true; + refocus?: boolean; +}; + +export type DialogToReset = { + id: number | string; + reset: true; +}; + +export type ExternalDialog = Omit & { + id?: number | string; +}; + +export const isDialog = (dialog: DialogT | DialogToRemove): dialog is DialogT => { + return !(dialog as DialogToRemove).remove; +}; + +class Observer { + subscribers: Array<(dialog: DialogT | DialogToRemove | DialogToReset) => void>; + dialogs: (DialogT | DialogToRemove | DialogToReset)[]; + + constructor() { + this.subscribers = []; + this.dialogs = []; + } + + subscribe = (subscriber: (dialog: DialogT | DialogToRemove | DialogToReset) => void) => { + this.subscribers.push(subscriber); + + return () => { + const index = this.subscribers.indexOf(subscriber); + this.subscribers.splice(index, 1); + }; + }; + + publish = (data: DialogT) => { + for (const subscriber of this.subscribers) { + subscriber(data); + } + }; + + set = (data: DialogT) => { + this.publish(data); + this.dialogs = [...this.dialogs, data]; + }; + + get = (id: number | string) => { + return this.dialogs.find((dialog) => dialog.id === id); + }; + + remove = (refocus = true, id?: number | string) => { + if (id) { + for (const subscriber of this.subscribers) { + subscriber({ id, remove: true, refocus }); + } + + return; + } + + // Remove all dialogs + for (const dialog of this.dialogs) { + for (const subscriber of this.subscribers) { + subscriber({ id: dialog.id, remove: true, refocus }); + } + } + }; + + //Update dialog + update = (id: number | string, data: Partial) => { + if (!id) return; + + for (const subscriber of this.subscribers) { + subscriber({ id, ...data }); + } + + return; + }; + + //Reset dialog + reset = (id?: number | string) => { + if (id) { + for (const subscriber of this.subscribers) { + subscriber({ id, reset: true }); + } + + return; + } + + // Reset all dialogs + for (const dialog of this.dialogs) { + for (const subscriber of this.subscribers) { + subscriber({ id: dialog.id, reset: true }); + } + } + }; +} + +export const DialogState = new Observer(); + +const dialogFunction = (content: React.ReactNode, data?: ExternalDialog) => { + const id = data?.id || dialogsCounter++; + + DialogState.set({ + content, + drawerOnMobile: true, + refocus: true, + autoFocus: true, + hideClose: false, + ...data, + id, + }); + return id; +}; + +export const dialog = Object.assign(dialogFunction, { + remove: DialogState.remove, + update: DialogState.update, + get: DialogState.get, + reset: DialogState.reset, +}); diff --git a/frontend/src/modules/common/down-alert.tsx b/frontend/src/modules/common/down-alert.tsx new file mode 100644 index 000000000..c1bb6a537 --- /dev/null +++ b/frontend/src/modules/common/down-alert.tsx @@ -0,0 +1,80 @@ +import { config } from 'config'; +import { CloudOff, Construction, X } from 'lucide-react'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { healthCheck } from '~/lib/health-check'; +import { Alert, AlertDescription } from '~/modules/ui/alert'; +import { useAlertStore } from '~/store/alert'; +import { Button } from '../ui/button'; + +export const DownAlert = () => { + const { t } = useTranslation(); + const { downAlert, setDownAlert } = useAlertStore(); + + // Check if the user is offline or online and handle accordingly + useEffect(() => { + let isMounted = true; + + const updateOnlineStatus = async () => { + if (navigator.onLine && downAlert === 'offline') { + setDownAlert(null); + } + if (!navigator.onLine && !downAlert) { + setDownAlert('offline'); + + const isBackendOnline = await healthCheck(`${config.backendUrl}/ping`); + if (isBackendOnline && isMounted) { + setDownAlert(null); + } + } + }; + + // Listen for online/offline changes + window.addEventListener('online', updateOnlineStatus); + window.addEventListener('offline', updateOnlineStatus); + + return () => { + window.removeEventListener('online', updateOnlineStatus); + window.removeEventListener('offline', updateOnlineStatus); + isMounted = false; + }; + }, [downAlert]); + + const cancelAlert = () => { + setDownAlert(null); + }; + + if (!downAlert) return; + + return ( +
    + + + {downAlert === 'maintenance' ? : } + + + {downAlert === 'maintenance' ? t('common:maintenance_mode') : t('common:offline_mode')} + · + {downAlert === 'maintenance' ? t('common:maintenance_mode.text') : t('common:offline_mode.text')} + {config.statusUrl && ( + + Try again later or check our server + · + + status + + . + + )} + + +
    + ); +}; diff --git a/frontend/src/modules/common/drop-indicator.tsx b/frontend/src/modules/common/drop-indicator.tsx new file mode 100644 index 000000000..0dd851bee --- /dev/null +++ b/frontend/src/modules/common/drop-indicator.tsx @@ -0,0 +1,34 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import type React from 'react'; +import { cn } from '~/lib/utils'; + +interface DropIndicatorProps { + edge: Edge; + className?: string; + gap?: string; +} + +const dropIndicatorEdgeStyles = { + top: (gap: string) => ({ + top: `calc(-1 * (${gap} / 2 + 4px / 2))`, + }), + bottom: (gap: string) => ({ + bottom: `calc(-1 * (${gap} / 2 + 4px / 2))`, + }), + left: () => {}, + right: () => {}, +}; + +export const DropIndicator: React.FC = ({ edge, className = '', gap = '0' }) => { + return ( +
    + ); +}; diff --git a/frontend/src/modules/common/dropdowner/index.tsx b/frontend/src/modules/common/dropdowner/index.tsx new file mode 100644 index 000000000..c7b968cbc --- /dev/null +++ b/frontend/src/modules/common/dropdowner/index.tsx @@ -0,0 +1,52 @@ +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBreakpoints } from '~/hooks/use-breakpoints'; +import { DropdownMenu, DropdownMenuContent } from '~/modules/ui/dropdown-menu'; +import { type DropDownT, type DropDownToRemove, dropDownState } from '../dropdowner/state'; + +export function DropDowner() { + const [dropDowns, setDropDowns] = useState([]); + const isMobile = useBreakpoints('max', 'sm'); + const prevFocusedElement = useRef(null); + + const removeDropDown = useCallback((dropDown: DropDownT | DropDownToRemove) => { + setDropDowns((dropDowns) => dropDowns.filter(({ id }) => id !== dropDown.id)); + if (dropDown.refocus && prevFocusedElement.current) { + // Timeout is needed to prevent focus from being stolen by the dropDown that was just removed + setTimeout(() => { + prevFocusedElement.current?.focus(); + prevFocusedElement.current = null; + }, 1); + } + }, []); + + useEffect(() => { + return dropDownState.subscribe((dropDown) => { + if ((dropDown as DropDownToRemove).remove) { + removeDropDown(dropDown as DropDownT); + return; + } + prevFocusedElement.current = (document.activeElement || document.body) as HTMLElement; + setDropDowns((dropDowns) => { + const existingDropDown = dropDowns.find(({ id }) => id === dropDown.id); + if (existingDropDown) return dropDowns; + return [...dropDowns, dropDown]; + }); + }); + }, []); + + if (!dropDowns.length) return null; + + return dropDowns.map((dropDown) => { + if (!isMobile || !dropDown.drawerOnMobile) { + return ( + + {dropDown.trigger} + + {dropDown.content} + + + ); + } + }); +} diff --git a/frontend/src/modules/common/dropdowner/state.ts b/frontend/src/modules/common/dropdowner/state.ts new file mode 100644 index 000000000..52688f4ac --- /dev/null +++ b/frontend/src/modules/common/dropdowner/state.ts @@ -0,0 +1,103 @@ +import type React from 'react'; + +let dropDownsCounter = 1; + +export type DropDownT = { + id: number | string; + drawerOnMobile?: boolean; + container?: HTMLElement | null; + className?: string; + refocus?: boolean; + autoFocus?: boolean; + hideClose?: boolean; + content?: React.ReactNode; + trigger?: React.ReactNode; +}; + +export type DropDownToRemove = { + id: number | string; + remove: true; + refocus?: boolean; +}; + +export type ExternalDropDown = Omit & { + id?: number | string; +}; + +export const isDropDown = (dropDown: DropDownT | DropDownToRemove): dropDown is DropDownT => { + return !(dropDown as DropDownToRemove).remove; +}; + +class Observer { + subscribers: Array<(dropDown: DropDownT | DropDownToRemove) => void>; + dropDowns: (DropDownT | DropDownToRemove)[]; + + constructor() { + this.subscribers = []; + this.dropDowns = []; + } + + subscribe = (subscriber: (dropDown: DropDownT | DropDownToRemove) => void) => { + this.subscribers.push(subscriber); + + return () => { + const index = this.subscribers.indexOf(subscriber); + this.subscribers.splice(index, 1); + }; + }; + + publish = (data: DropDownT) => { + for (const subscriber of this.subscribers) { + subscriber(data); + } + }; + + set = (data: DropDownT) => { + this.publish(data); + this.dropDowns = [...this.dropDowns, data]; + }; + + get = (id: number | string) => { + return this.dropDowns.find((dropDown) => dropDown.id === id); + }; + + remove = (refocus = true, id?: number | string) => { + if (id) { + for (const subscriber of this.subscribers) { + subscriber({ id, remove: true, refocus }); + } + + return; + } + + // Remove all dropDowns + for (const dropDown of this.dropDowns) { + for (const subscriber of this.subscribers) { + subscriber({ id: dropDown.id, remove: true, refocus }); + } + } + }; +} + +export const dropDownState = new Observer(); + +const dropDownFunction = (content: React.ReactNode, trigger: React.ReactNode, data?: ExternalDropDown) => { + const id = data?.id || dropDownsCounter++; + + dropDownState.set({ + trigger, + content, + drawerOnMobile: true, + refocus: true, + autoFocus: true, + hideClose: false, + ...data, + id, + }); + return id; +}; + +export const dropDown = Object.assign(dropDownFunction, { + remove: dropDownState.remove, + get: dropDownState.get, +}); diff --git a/frontend/src/modules/common/electric/electrify.ts b/frontend/src/modules/common/electric/electrify.ts new file mode 100644 index 000000000..6a35ee2ac --- /dev/null +++ b/frontend/src/modules/common/electric/electrify.ts @@ -0,0 +1,13 @@ +import { makeElectricContext } from 'electric-sql/react'; +import type { Electric } from '~/generated/client'; +import type { Tasks as BaseTask, Labels as Label } from '~/generated/client'; + +export { schema } from '~/generated/client'; +export type { Electric, Label }; + +export type Task = Omit & { + labels: string[] | null; + assigned_to: string[] | null; +}; + +export const { ElectricProvider, useElectric } = makeElectricContext(); diff --git a/frontend/src/modules/common/electric/index.tsx b/frontend/src/modules/common/electric/index.tsx new file mode 100644 index 000000000..616072f69 --- /dev/null +++ b/frontend/src/modules/common/electric/index.tsx @@ -0,0 +1,127 @@ +import { config } from 'config'; +import { ElectricDatabase, electrify } from 'electric-sql/browser'; +import { uniqueTabId } from 'electric-sql/util'; +import { LIB_VERSION } from 'electric-sql/version'; +import { Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Alert, AlertDescription } from '~/modules/ui/alert'; +import { useNavigationStore } from '~/store/navigation'; +import { useUserStore } from '~/store/user'; +import { ElectricProvider as BaseElectricProvider, type Electric, schema } from './electrify'; +interface Props { + children: React.ReactNode; +} + +function deleteDB(dbName: string) { + console.info('Deleting DB due to schema mismatch in relation to server'); + const DBDeleteRequest = window.indexedDB.deleteDatabase(dbName); + DBDeleteRequest.onsuccess = () => { + console.info('Database deleted successfully'); + }; + // the indexedDB cannot be deleted if the database connection is still open, + // so we need to reload the page to close any open connections. + // On reload, the database will be recreated. + window.location.reload(); +} + +const ElectricProvider = ({ children }: Props) => { + const user = useUserStore((state) => state.user); + // TODO: Temporary fix to prevent loading all projects and labels. Only sync the projects and labels of organizations the user is a member of. + // TODO: Consider exposing organizationIds the user is part of on the user object, as the menu can be undefined. + const { menu } = useNavigationStore(); + + const [electric, setElectric] = useState(); + + // TODO: can we move this out of a useEffect? + useEffect(() => { + let isMounted = true; + + const debug = config.mode === 'development'; + const { tabId } = uniqueTabId(); + const scopedDbName = `basic-${LIB_VERSION}-${tabId}.db`; + + const init = async () => { + try { + const conn = await ElectricDatabase.init(scopedDbName); + const electric = await electrify(conn, schema, { + debug: debug, + url: config.electricUrl, + }); + + // Wait until the user is loaded + if (!isMounted || !user || !user.electricJWTToken) { + return; + } + + // Connect to the server with the user's JWT token. + await electric.connect(user.electricJWTToken); + + if (debug) { + const { addToolbar } = await import('@electric-sql/debug-toolbar'); + addToolbar(electric); + + const toolbarNode = document.getElementById('__electric_debug_toolbar_container'); + if (toolbarNode) toolbarNode.classList.add('hidden'); + } + + setElectric(electric); + + // Resolves when the shape subscription has been established. + // TODO: Improve the following section by deriving organization IDs differently. + // TODO: Update organizationIds to sync whenever the user's menu changes. + const organizationIds = menu.organizations.map((item) => item.id); + const tasksShape = await electric.db.tasks.sync({ + where: { + organization_id: { + in: organizationIds, + }, + }, + }); + + const labelsShape = await electric.db.labels.sync({ + where: { + organization_id: { + in: organizationIds, + }, + }, + }); + + // Resolves when the data has been synced into the local database. + await tasksShape.synced; + await labelsShape.synced; + + const timeToSync = performance.now(); + if (debug) console.log(`Synced in ${timeToSync}ms from page load`); + } catch (error) { + if ((error as Error).message.startsWith('Local database schema mismatches with server schema')) { + deleteDB(scopedDbName); + } + throw error; + } + }; + + init(); + + return () => { + isMounted = false; + }; + }, [user]); + + return ( + <> + {children} + {electric === undefined && ( +
    + + + + Initializing local database + + +
    + )} + + ); +}; + +export default ElectricProvider; diff --git a/frontend/src/modules/common/electric/suspense.tsx b/frontend/src/modules/common/electric/suspense.tsx new file mode 100644 index 000000000..2bf4f0079 --- /dev/null +++ b/frontend/src/modules/common/electric/suspense.tsx @@ -0,0 +1,12 @@ +import { useElectric } from './electrify'; +import Spinner from '../spinner'; + +const ElectricSuspense = ({ children }: { children: React.ReactNode }) => { + const Electric = useElectric(); + + if (!Electric) return ; + + return children; +}; + +export default ElectricSuspense; diff --git a/frontend/src/modules/common/error-notice.tsx b/frontend/src/modules/common/error-notice.tsx new file mode 100644 index 000000000..34fb5eb03 --- /dev/null +++ b/frontend/src/modules/common/error-notice.tsx @@ -0,0 +1,131 @@ +import { useRouterState } from '@tanstack/react-router'; +import type { ErrorType } from 'backend/lib/errors'; +import { ChevronDown, Home, MessageCircleQuestion, RefreshCw } from 'lucide-react'; +import type React from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppFooter } from '~/modules/common/app-footer'; +import { Button } from '~/modules/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/modules/ui/card'; +import { dialog } from './dialoger/state'; +import ContactForm from './contact-form/contact-form'; + +interface ErrorNoticeProps { + error?: ErrorType; + resetErrorBoundary?: () => void; + isRootLevel?: boolean; +} + +const ErrorNotice: React.FC = ({ error, resetErrorBoundary, isRootLevel }) => { + const { t } = useTranslation(); + const { location } = useRouterState(); + const dateNow = new Date().toUTCString(); + + const [showError, setShowError] = useState(false); + + const handleReload = () => { + if (resetErrorBoundary) resetErrorBoundary(); + window.location.reload(); + }; + + const handleGoToHome = () => { + if (resetErrorBoundary) resetErrorBoundary(); + window.location.replace('/'); + }; + + const handleAskForHelp = () => { + // Not on every page we have footer e.g. workspace + // if (!window.Gleap) return document.dispatchEvent(new CustomEvent('openContactForm')); + if (!window.Gleap) { + return dialog(, { + id: 'contact-form', + drawerOnMobile: false, + className: 'sm:max-w-5xl', + title: t('common:contact_us'), + text: t('common:contact_us.text'), + }); + } + window.Gleap.openConversations(); + }; + return ( +
    +
    + + + + {error?.entityType + ? t(`common:error.resource_${error.type}`, { resource: t(error.entityType.toLowerCase()) }) + : error?.type + ? t(`common:error.${error.type}`) + : error?.message || t('common:error.error')} + + + + {error?.entityType + ? t(`common:error.resource_${error.type}.text`, { resource: t(error.entityType.toLowerCase()).toLowerCase() }) + : error?.type + ? t(`common:error.${error.type}.text`) + : error?.message || t('common:error.reported_try_or_contact')} + + {error?.severity && error.severity === 'warn' && t('common:error.contact_mistake')} + {error?.severity && error.severity === 'error' && t('common:error.try_again_later')} + + + {error && ( + + {error.type && !showError && ( + + )} + {error.type && showError && ( +
    +
    Log ID
    +
    {error.logId || 'na'}
    +
    Timestamp
    +
    {dateNow}
    +
    Message
    +
    {error.message || 'na'}
    +
    Type
    +
    {error.type || 'na'}
    +
    Resource type
    +
    {error.entityType || 'na'}
    +
    HTTP status
    +
    {error.status || 'na'}
    +
    Severity
    +
    {error.severity || 'na'}
    +
    User ID
    +
    {error.usr || 'na'}
    +
    Organization ID
    +
    {error.org || 'na'}
    +
    + )} +
    + )} + + + {!location.pathname.startsWith('/error') && ( + + )} + {error?.severity && ['warn', 'error'].includes(error.severity) && ( + + )} + +
    + {isRootLevel && } +
    +
    + ); +}; + +export default ErrorNotice; diff --git a/frontend/src/modules/common/expandable-list.tsx b/frontend/src/modules/common/expandable-list.tsx new file mode 100644 index 000000000..7d34464ad --- /dev/null +++ b/frontend/src/modules/common/expandable-list.tsx @@ -0,0 +1,38 @@ +import { ChevronDown } from 'lucide-react'; +import type React from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '~/modules/ui/badge'; +import { Button } from '~/modules/ui/button'; + +interface ExpandableListProps { + items: unknown[]; + // biome-ignore lint/suspicious/noExplicitAny: the component doesn't do anything with items + renderItem: (item: any, index: number) => React.ReactNode; + initialDisplayCount: number; + alwaysShowAll?: boolean; + expandText: string; +} + +export const ExpandableList = ({ items, renderItem, initialDisplayCount, alwaysShowAll, expandText }: ExpandableListProps) => { + const { t } = useTranslation(); + const [displayCount, setDisplayCount] = useState(alwaysShowAll ? items.length : initialDisplayCount); + + const handleLoadMore = () => { + setDisplayCount(items.length); + }; + + const visibleItems = items.slice(0, displayCount); + return ( + <> + {visibleItems.map(renderItem)} + {displayCount < items.length && ( + + )} + + ); +}; diff --git a/frontend/src/modules/common/focus-view.tsx b/frontend/src/modules/common/focus-view.tsx new file mode 100644 index 000000000..91146334c --- /dev/null +++ b/frontend/src/modules/common/focus-view.tsx @@ -0,0 +1,55 @@ +import { Expand, Shrink } from 'lucide-react'; +import type React from 'react'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { cn } from '~/lib/utils'; +import { TooltipButton } from '~/modules/common/tooltip-button'; +import { Button } from '~/modules/ui/button'; +import { useNavigationStore } from '~/store/navigation'; + +interface FocusViewProps { + className?: string; + iconOnly?: boolean; +} + +export const FocusView = ({ className = '', iconOnly }: FocusViewProps) => { + const { t } = useTranslation(); + const { focusView, setFocusView } = useNavigationStore(); + + const toggleFocus = () => { + toast.success(focusView ? t('common:left_focus.text') : t('common:entered_focus.text')); + setFocusView(!focusView); + window.scrollTo(0, 0); + }; + + return ( + + + + ); +}; + +export const FocusViewContainer = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => { + const { focusView, setFocusView } = useNavigationStore(); + + useEffect(() => { + const body = document.body.classList; + focusView ? body.add('focus-view') : body.remove('focus-view'); + + return () => { + body.remove('focus-view'); + }; + }, [focusView]); + + useEffect(() => { + return () => { + setFocusView(false); + }; + }, []); + + return
    {children}
    ; +}; diff --git a/frontend/src/modules/common/form-fields/avatar.tsx b/frontend/src/modules/common/form-fields/avatar.tsx new file mode 100644 index 000000000..cad458bd3 --- /dev/null +++ b/frontend/src/modules/common/form-fields/avatar.tsx @@ -0,0 +1,33 @@ +import type { Control } from 'react-hook-form'; +import { FormControl, FormField, FormItem, FormLabel } from '~/modules/ui/form'; +import { UploadAvatar } from '../upload/upload-avatar'; + +interface Props { + control: Control; + name: string; + label: string; + entity: { + id?: string; + name?: string | null; + }; + type: Parameters[0]['type']; + url?: string | null; + setUrl: (url: string) => void; +} + +const AvatarFormField = ({ control, label, name, entity, type, url, setUrl }: Props) => ( + ( + + {label} + + + + + )} + /> +); + +export default AvatarFormField; diff --git a/frontend/src/modules/common/form-fields/domains.tsx b/frontend/src/modules/common/form-fields/domains.tsx new file mode 100644 index 000000000..eb68db8ab --- /dev/null +++ b/frontend/src/modules/common/form-fields/domains.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import { useFormContext, type Control } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { type Tag, TagInput } from 'emblor'; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/form'; + +type Props = { + control: Control; + label: string; + description?: string; + required?: boolean; +}; + +const DomainsFormField = ({ control, label, description, required }: Props) => { + const { t } = useTranslation(); + const { getValues } = useFormContext(); + const formValue = getValues('emailDomains'); + const [fieldActive, setFieldActive] = useState(false); + const [domains, setDomains] = useState(formValue.map((dom: string) => ({ id: dom, text: dom }))); + const [currentValue, setCurrentValue] = useState(''); + const checkValidDomain = (domain: string) => { + return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/i.test(domain.trim()); + }; + + const checkValidInput = (value: string) => { + if (!value || value.trim().length < 2) return true; + return checkValidDomain(value); + }; + + useEffect(() => { + setDomains(formValue.map((dom: string) => ({ id: dom, text: dom }))); + }, [formValue]); + + return ( + { + return ( + + + {label} + {required && *} + + {description && {description}} + + setCurrentValue(newValue)} + onFocus={() => setFieldActive(true)} + onBlur={() => { + if (checkValidDomain(currentValue)) setDomains((prev) => [...prev, { id: currentValue, text: currentValue }]); + setCurrentValue(''); + setFieldActive(false); + }} + maxLength={100} + minLength={4} + placeholder={t('common:placeholder.email_domains')} + tags={domains} + allowDuplicates={false} + setTags={(newTags) => { + setDomains(newTags); + if (Array.isArray(newTags)) onChange(newTags.map((tag) => tag.text)); + setCurrentValue(''); + }} + validateTag={checkValidDomain} + activeTagIndex={null} + setActiveTagIndex={() => {}} + styleClasses={{ + input: 'px-1 py-0 h-[1.38rem]', + tag: { body: 'h-[1.38rem] py-0' }, + inlineTagsContainer: `${fieldActive ? (!checkValidInput(currentValue) ? 'ring-2 focus-visible:ring-2 ring-red-500 focus-visible:ring-red-500' : 'ring-2 ring-offset-2 ring-white') : ''}`, + }} + /> + + + + ); + }} + /> + ); +}; + +export default DomainsFormField; diff --git a/frontend/src/modules/common/form-fields/input.tsx b/frontend/src/modules/common/form-fields/input.tsx new file mode 100644 index 000000000..a4fbcf700 --- /dev/null +++ b/frontend/src/modules/common/form-fields/input.tsx @@ -0,0 +1,125 @@ +import { type ReactNode, useEffect, useState, useRef } from 'react'; +import { type Control, type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/form'; +import { Input } from '~/modules/ui/input'; +import { Textarea } from '~/modules/ui/textarea'; + +interface Props { + control: Control; + name: keyof TFieldValues; + label: string; + value?: string; + defaultValue?: string; + type?: Parameters[0]['type'] | 'textarea'; + description?: string; + placeholder?: string; + onFocus?: () => void; + minimal?: boolean; + prefix?: string; + subComponent?: React.ReactNode; + required?: boolean; + disabled?: boolean; + icon?: ReactNode; + inputClassName?: string; +} + +const InputFormField = ({ + control, + name, + label, + value, + defaultValue, + description, + onFocus, + type = 'text', + placeholder, + subComponent, + required, + prefix, + disabled, + icon, + inputClassName, +}: Props) => { + const { setFocus } = useFormContext(); + const [prefixPadding, setPrefixPadding] = useState('12px'); + const [subComponentPadding, setSubComponentPadding] = useState('12px'); + const prefixRef = useRef(null); + const subComponentRef = useRef(null); + + useEffect(() => { + if (prefix && prefixRef.current) { + const prefixRefWidth = prefixRef.current.offsetWidth; + const prefixWidth = prefixRefWidth === 0 ? prefix.length * 6 : prefixRefWidth; + setPrefixPadding(`${prefixWidth + 16}px`); + } + if (subComponent && subComponentRef.current?.children[0]) { + const element = subComponentRef.current?.children[0] as HTMLElement; + setSubComponentPadding(`${element.offsetWidth + 6}px`); + } + if (!subComponent) setSubComponentPadding('12px'); + if (!prefix) setSubComponentPadding('12px'); + }, [subComponent, prefix]); + + const prefixClick = () => { + setFocus(name.toString()); + }; + + return ( + } + render={({ field: { value: formFieldValue, ...rest } }) => ( + + + {label} + {required && *} + + {description && {description}} + +
    + {(prefix || icon) && ( + // biome-ignore lint/a11y/useKeyWithClickEvents: + + {prefix || icon} + + )} + {type === 'textarea' ? ( +