diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c4b5384..857ff94 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(psql:*)", "Bash(pnpm test:*)", "Bash(pnpm vitest run:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(pnpm db:generate:*)", + "Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)" ] } } diff --git a/drizzle/0003_sparkling_ares.sql b/drizzle/0003_sparkling_ares.sql new file mode 100644 index 0000000..1ec80fe --- /dev/null +++ b/drizzle/0003_sparkling_ares.sql @@ -0,0 +1,11 @@ +CREATE TYPE "public"."membership_role" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint +CREATE TABLE "projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "memberships" ALTER COLUMN "role" SET DATA TYPE "public"."membership_role" USING "role"::"public"."membership_role";--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0004_dear_zuras.sql b/drizzle/0004_dear_zuras.sql new file mode 100644 index 0000000..29ff552 --- /dev/null +++ b/drizzle/0004_dear_zuras.sql @@ -0,0 +1,27 @@ +CREATE TABLE "packages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "vcpu" integer NOT NULL, + "ram" integer NOT NULL, + "disk" integer NOT NULL, + "price_monthly" numeric(10, 2) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "packages_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "wms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "name" text NOT NULL, + "package_id" uuid, + "vcpu" integer NOT NULL, + "ram" integer NOT NULL, + "disk" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "wms" ADD CONSTRAINT "wms_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wms" ADD CONSTRAINT "wms_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."packages"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0005_amazing_supernaut.sql b/drizzle/0005_amazing_supernaut.sql new file mode 100644 index 0000000..81ef6c7 --- /dev/null +++ b/drizzle/0005_amazing_supernaut.sql @@ -0,0 +1,2 @@ +ALTER TABLE "packages" ALTER COLUMN "vcpu" SET DATA TYPE real;--> statement-breakpoint +ALTER TABLE "wms" ALTER COLUMN "vcpu" SET DATA TYPE real; \ No newline at end of file diff --git a/drizzle/0006_seed_packages.sql b/drizzle/0006_seed_packages.sql new file mode 100644 index 0000000..4e2915f --- /dev/null +++ b/drizzle/0006_seed_packages.sql @@ -0,0 +1,7 @@ +INSERT INTO "packages" ("slug", "name", "vcpu", "ram", "disk", "price_monthly") +VALUES + ('sandbox', 'Sandbox', 0.25, 256, 1, '0.00'), + ('nano', 'Nano', 0.5, 512, 5, '3.00'), + ('standard', 'Standard', 1, 1024, 10, '6.00'), + ('pro', 'Pro', 4, 4096, 20, '18.00') +ON CONFLICT ("slug") DO NOTHING; diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..44860bb --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,397 @@ +{ + "id": "68978153-b70a-48ef-bb92-88598f5319cc", + "prevId": "aff5e3b9-ae84-4896-8198-eec50eb41dd9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..c3921a5 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,572 @@ +{ + "id": "3fe6ec31-b399-41bd-88d8-b1b855d5b3ad", + "prevId": "68978153-b70a-48ef-bb92-88598f5319cc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_monthly": { + "name": "price_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wms": { + "name": "wms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vcpu": { + "name": "vcpu", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wms_project_id_projects_id_fk": { + "name": "wms_project_id_projects_id_fk", + "tableFrom": "wms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wms_package_id_packages_id_fk": { + "name": "wms_package_id_packages_id_fk", + "tableFrom": "wms", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..60e8c64 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,572 @@ +{ + "id": "08d293e6-8a0d-4abf-bbe5-600133a8d287", + "prevId": "3fe6ec31-b399-41bd-88d8-b1b855d5b3ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_monthly": { + "name": "price_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wms": { + "name": "wms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wms_project_id_projects_id_fk": { + "name": "wms_project_id_projects_id_fk", + "tableFrom": "wms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wms_package_id_packages_id_fk": { + "name": "wms_package_id_packages_id_fk", + "tableFrom": "wms", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..60e8c64 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,572 @@ +{ + "id": "08d293e6-8a0d-4abf-bbe5-600133a8d287", + "prevId": "3fe6ec31-b399-41bd-88d8-b1b855d5b3ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_monthly": { + "name": "price_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wms": { + "name": "wms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wms_project_id_projects_id_fk": { + "name": "wms_project_id_projects_id_fk", + "tableFrom": "wms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wms_package_id_packages_id_fk": { + "name": "wms_package_id_packages_id_fk", + "tableFrom": "wms", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d0a2d83..686573a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,34 @@ "when": 1770481466355, "tag": "0002_fast_tarot", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1770544024156, + "tag": "0003_sparkling_ares", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1770544905186, + "tag": "0004_dear_zuras", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1770547190763, + "tag": "0005_amazing_supernaut", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1770547300000, + "tag": "0006_seed_packages", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 61e30c9..3d0fc99 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { boolean, integer, pgEnum, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { boolean, integer, numeric, pgEnum, pgTable, real, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; export const membershipRoleEnum = pgEnum("membership_role", ["owner", "admin", "member"]); @@ -43,6 +43,42 @@ export const memberships = pgTable( (t) => [unique().on(t.userId, t.accountId)], ); +export const projects = pgTable("projects", { + id: uuid().defaultRandom().primaryKey(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id), + name: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const packages = pgTable("packages", { + id: uuid().defaultRandom().primaryKey(), + slug: text().notNull().unique(), + name: text().notNull(), + vcpu: real().notNull(), + ram: integer().notNull(), + disk: integer().notNull(), + priceMonthly: numeric("price_monthly", { precision: 10, scale: 2 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const wms = pgTable("wms", { + id: uuid().defaultRandom().primaryKey(), + projectId: uuid("project_id") + .notNull() + .references(() => projects.id), + name: text().notNull(), + packageId: uuid("package_id").references(() => packages.id), + vcpu: real().notNull(), + ram: integer().notNull(), + disk: integer().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + export const sessions = pgTable("sessions", { id: uuid().defaultRandom().primaryKey(), userId: uuid("user_id") diff --git a/src/routes/index.ts b/src/routes/index.ts index e02ff0a..71a3e87 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,6 +5,9 @@ import { signupRoutes } from "./signup.js"; import { sessionRoutes } from "./sessions.js"; import { meRoutes } from "./me.js"; import { accountRoutes } from "./accounts.js"; +import { projectRoutes } from "./projects.js"; +import { packageRoutes } from "./packages.js"; +import { wmRoutes } from "./wms.js"; export async function registerRoutes(app: FastifyInstance) { app.register(healthRoutes); @@ -13,4 +16,7 @@ export async function registerRoutes(app: FastifyInstance) { app.register(sessionRoutes, { prefix: "/sessions" }); app.register(meRoutes); app.register(accountRoutes); + app.register(projectRoutes); + app.register(packageRoutes); + app.register(wmRoutes); } diff --git a/src/routes/packages.ts b/src/routes/packages.ts new file mode 100644 index 0000000..a99bc5c --- /dev/null +++ b/src/routes/packages.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from "fastify"; +import { db } from "../db/index.js"; +import { packages } from "../db/schema.js"; + +export async function packageRoutes(app: FastifyInstance) { + // GET /packages — list available packages + app.get( + "/packages", + { + schema: { + description: "List available WM packages", + tags: ["Packages"], + response: { + 200: { + description: "List of packages", + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + slug: { type: "string" }, + name: { type: "string" }, + vcpu: { type: "number" }, + ram: { type: "integer" }, + disk: { type: "integer" }, + priceMonthly: { type: "string" }, + }, + }, + }, + }, + }, + }, + async () => { + return db.select().from(packages); + }, + ); +} diff --git a/src/routes/projects.ts b/src/routes/projects.ts new file mode 100644 index 0000000..2e30a1c --- /dev/null +++ b/src/routes/projects.ts @@ -0,0 +1,187 @@ +import type { FastifyInstance } from "fastify"; +import { eq, and } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { projects } from "../db/schema.js"; + +export async function projectRoutes(app: FastifyInstance) { + // GET /projects — list projects for the current account + app.get( + "/projects", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "List all projects for the current account", + tags: ["Projects"], + security: [{ bearerAuth: [] }], + response: { + 200: { + description: "List of projects", + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + async (request) => { + return db + .select() + .from(projects) + .where(eq(projects.accountId, request.accountId)); + }, + ); + + // POST /projects — create a new project + app.post<{ Body: { name: string } }>( + "/projects", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Create a new project in the current account", + tags: ["Projects"], + security: [{ bearerAuth: [] }], + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1 }, + }, + }, + response: { + 201: { + description: "Project created successfully", + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + accountId: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const [project] = await db + .insert(projects) + .values({ name: request.body.name, accountId: request.accountId }) + .returning(); + + return reply.status(201).send(project); + }, + ); + + // PATCH /projects/:id — update a project + app.patch<{ Params: { id: string }; Body: { name: string } }>( + "/projects/:id", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Update a project", + tags: ["Projects"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + }, + }, + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: { + description: "Project updated successfully", + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + accountId: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + 404: { + description: "Project not found", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const [project] = await db + .update(projects) + .set({ name: request.body.name, updatedAt: new Date() }) + .where( + and(eq(projects.id, request.params.id), eq(projects.accountId, request.accountId)), + ) + .returning(); + + if (!project) { + return reply.status(404).send({ error: "Project not found" }); + } + + return project; + }, + ); + + // DELETE /projects/:id — delete a project + app.delete<{ Params: { id: string } }>( + "/projects/:id", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Delete a project", + tags: ["Projects"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + }, + }, + response: { + 204: { + description: "Project deleted successfully", + }, + 404: { + description: "Project not found", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const [deleted] = await db + .delete(projects) + .where( + and(eq(projects.id, request.params.id), eq(projects.accountId, request.accountId)), + ) + .returning(); + + if (!deleted) { + return reply.status(404).send({ error: "Project not found" }); + } + + return reply.status(204).send(); + }, + ); +} diff --git a/src/routes/wms.ts b/src/routes/wms.ts new file mode 100644 index 0000000..a58e75a --- /dev/null +++ b/src/routes/wms.ts @@ -0,0 +1,308 @@ +import type { FastifyInstance } from "fastify"; +import { eq, and } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { wms, packages, projects } from "../db/schema.js"; + +export async function wmRoutes(app: FastifyInstance) { + // GET /projects/:projectId/wms — list WMs for a project + app.get<{ Params: { projectId: string } }>( + "/projects/:projectId/wms", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "List all WMs for a project", + tags: ["WMs"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + projectId: { type: "string", format: "uuid" }, + }, + }, + response: { + 200: { + description: "List of WMs", + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + projectId: { type: "string" }, + name: { type: "string" }, + packageId: { type: "string", nullable: true }, + vcpu: { type: "number" }, + ram: { type: "integer" }, + disk: { type: "integer" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + 404: { + description: "Project not found", + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + // Verify project belongs to the account + const [project] = await db + .select() + .from(projects) + .where( + and(eq(projects.id, request.params.projectId), eq(projects.accountId, request.accountId)), + ); + + if (!project) { + return reply.status(404).send({ error: "Project not found" }); + } + + return db.select().from(wms).where(eq(wms.projectId, request.params.projectId)); + }, + ); + + // POST /projects/:projectId/wms — create a WM + app.post<{ + Params: { projectId: string }; + Body: { name: string; packageId?: string; vcpu?: number; ram?: number; disk?: number }; + }>( + "/projects/:projectId/wms", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: + "Create a WM from a package or with custom settings. Provide packageId to use a package, or vcpu/ram/disk for custom settings.", + tags: ["WMs"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + projectId: { type: "string", format: "uuid" }, + }, + }, + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1 }, + packageId: { type: "string", format: "uuid" }, + vcpu: { type: "number", minimum: 0.25 }, + ram: { type: "integer", minimum: 1 }, + disk: { type: "integer", minimum: 1 }, + }, + }, + response: { + 201: { + description: "WM created successfully", + type: "object", + properties: { + id: { type: "string" }, + projectId: { type: "string" }, + name: { type: "string" }, + packageId: { type: "string", nullable: true }, + vcpu: { type: "number" }, + ram: { type: "integer" }, + disk: { type: "integer" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + 400: { + description: "Invalid input", + type: "object", + properties: { error: { type: "string" } }, + }, + 404: { + description: "Project or package not found", + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + const { name, packageId, vcpu, ram, disk } = request.body; + + // Verify project belongs to the account + const [project] = await db + .select() + .from(projects) + .where( + and(eq(projects.id, request.params.projectId), eq(projects.accountId, request.accountId)), + ); + + if (!project) { + return reply.status(404).send({ error: "Project not found" }); + } + + let settings: { vcpu: number; ram: number; disk: number; packageId: string | null }; + + if (packageId) { + const [pkg] = await db.select().from(packages).where(eq(packages.id, packageId)); + if (!pkg) { + return reply.status(404).send({ error: "Package not found" }); + } + settings = { vcpu: pkg.vcpu, ram: pkg.ram, disk: pkg.disk, packageId: pkg.id }; + } else if (vcpu !== undefined && ram !== undefined && disk !== undefined) { + settings = { vcpu, ram, disk, packageId: null }; + } else { + return reply + .status(400) + .send({ error: "Provide either packageId or all of vcpu, ram, and disk" }); + } + + const [wm] = await db + .insert(wms) + .values({ + projectId: request.params.projectId, + name, + ...settings, + }) + .returning(); + + return reply.status(201).send(wm); + }, + ); + + // PATCH /projects/:projectId/wms/:id — update a WM + app.patch<{ + Params: { projectId: string; id: string }; + Body: { name?: string; vcpu?: number; ram?: number; disk?: number }; + }>( + "/projects/:projectId/wms/:id", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Update a WM's name or settings", + tags: ["WMs"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + projectId: { type: "string", format: "uuid" }, + id: { type: "string", format: "uuid" }, + }, + }, + body: { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + vcpu: { type: "number", minimum: 0.25 }, + ram: { type: "integer", minimum: 1 }, + disk: { type: "integer", minimum: 1 }, + }, + }, + response: { + 200: { + description: "WM updated successfully", + type: "object", + properties: { + id: { type: "string" }, + projectId: { type: "string" }, + name: { type: "string" }, + packageId: { type: "string", nullable: true }, + vcpu: { type: "number" }, + ram: { type: "integer" }, + disk: { type: "integer" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + 404: { + description: "WM not found", + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + // Verify project belongs to the account + const [project] = await db + .select() + .from(projects) + .where( + and(eq(projects.id, request.params.projectId), eq(projects.accountId, request.accountId)), + ); + + if (!project) { + return reply.status(404).send({ error: "Project not found" }); + } + + const { name, vcpu, ram, disk } = request.body; + const updates: Record = { updatedAt: new Date() }; + if (name !== undefined) updates.name = name; + if (vcpu !== undefined) updates.vcpu = vcpu; + if (ram !== undefined) updates.ram = ram; + if (disk !== undefined) updates.disk = disk; + + const [wm] = await db + .update(wms) + .set(updates) + .where(and(eq(wms.id, request.params.id), eq(wms.projectId, request.params.projectId))) + .returning(); + + if (!wm) { + return reply.status(404).send({ error: "WM not found" }); + } + + return wm; + }, + ); + + // DELETE /projects/:projectId/wms/:id — delete a WM + app.delete<{ Params: { projectId: string; id: string } }>( + "/projects/:projectId/wms/:id", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Delete a WM", + tags: ["WMs"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + projectId: { type: "string", format: "uuid" }, + id: { type: "string", format: "uuid" }, + }, + }, + response: { + 204: { + description: "WM deleted successfully", + }, + 404: { + description: "WM not found", + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + // Verify project belongs to the account + const [project] = await db + .select() + .from(projects) + .where( + and(eq(projects.id, request.params.projectId), eq(projects.accountId, request.accountId)), + ); + + if (!project) { + return reply.status(404).send({ error: "Project not found" }); + } + + const [deleted] = await db + .delete(wms) + .where(and(eq(wms.id, request.params.id), eq(wms.projectId, request.params.projectId))) + .returning(); + + if (!deleted) { + return reply.status(404).send({ error: "WM not found" }); + } + + return reply.status(204).send(); + }, + ); +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 765fdd3..032f7cc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,7 +1,7 @@ import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; import { db } from "../src/db/index.js"; -import { users, otpCodes, sessions, accounts, memberships } from "../src/db/schema.js"; +import { users, otpCodes, sessions, accounts, memberships, projects, wms, packages } from "../src/db/schema.js"; import { sql } from "drizzle-orm"; export async function createTestApp(): Promise { @@ -11,6 +11,9 @@ export async function createTestApp(): Promise { } export async function cleanDb() { + await db.delete(wms); + await db.delete(packages); + await db.delete(projects); await db.delete(sessions); await db.delete(memberships); await db.delete(accounts); diff --git a/tests/packages.test.ts b/tests/packages.test.ts new file mode 100644 index 0000000..fd3b950 --- /dev/null +++ b/tests/packages.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb } from "./helpers.js"; +import { db } from "../src/db/index.js"; +import { packages } from "../src/db/schema.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("GET /packages", () => { + it("returns empty list when no packages exist", async () => { + const res = await app.inject({ method: "GET", url: "/packages" }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + + it("returns seeded packages", async () => { + await db.insert(packages).values([ + { slug: "small", name: "Small", vcpu: 1, ram: 1024, disk: 20, priceMonthly: "5.00" }, + { slug: "medium", name: "Medium", vcpu: 2, ram: 2048, disk: 40, priceMonthly: "10.00" }, + ]); + + const res = await app.inject({ method: "GET", url: "/packages" }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveLength(2); + expect(body.map((p: { slug: string }) => p.slug).sort()).toEqual(["medium", "small"]); + }); + + it("does not require authentication", async () => { + const res = await app.inject({ method: "GET", url: "/packages" }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/tests/projects.test.ts b/tests/projects.test.ts new file mode 100644 index 0000000..d965f4b --- /dev/null +++ b/tests/projects.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb, signupUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +async function setupUser() { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken, accounts } = signup.json(); + return { accessToken, accountId: accounts[0].id as string }; +} + +function headers(accessToken: string, accountId: string) { + return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }; +} + +describe("GET /projects", () => { + it("returns empty list when no projects exist", async () => { + const { accessToken, accountId } = await setupUser(); + + const res = await app.inject({ + method: "GET", + url: "/projects", + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + + it("returns projects for the account", async () => { + const { accessToken, accountId } = await setupUser(); + + await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Project A" }, + }); + await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Project B" }, + }); + + const res = await app.inject({ + method: "GET", + url: "/projects", + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveLength(2); + expect(body.map((p: { name: string }) => p.name).sort()).toEqual(["Project A", "Project B"]); + }); + + it("does not return projects from another account", async () => { + const { accessToken, accountId } = await setupUser(); + + // Create a project in the first account + await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Project A" }, + }); + + // Create a second account + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + // List projects in the second account — should be empty + const res = await app.inject({ + method: "GET", + url: "/projects", + headers: headers(accessToken, secondAccountId), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + + it("returns 401 without auth", async () => { + const res = await app.inject({ method: "GET", url: "/projects" }); + expect(res.statusCode).toBe(401); + }); +}); + +describe("POST /projects", () => { + it("creates a project", async () => { + const { accessToken, accountId } = await setupUser(); + + const res = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "New Project" }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe("New Project"); + expect(body.accountId).toBe(accountId); + expect(body.id).toBeDefined(); + }); + + it("returns 400 if name is missing", async () => { + const { accessToken, accountId } = await setupUser(); + + const res = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); +}); + +describe("PATCH /projects/:id", () => { + it("updates a project name", async () => { + const { accessToken, accountId } = await setupUser(); + + const create = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Old Name" }, + }); + const projectId = create.json().id; + + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}`, + headers: headers(accessToken, accountId), + payload: { name: "New Name" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe("New Name"); + expect(res.json().id).toBe(projectId); + }); + + it("returns 404 for non-existent project", async () => { + const { accessToken, accountId } = await setupUser(); + + const res = await app.inject({ + method: "PATCH", + url: "/projects/00000000-0000-0000-0000-000000000000", + headers: headers(accessToken, accountId), + payload: { name: "Nope" }, + }); + + expect(res.statusCode).toBe(404); + }); + + it("returns 404 when updating a project from another account", async () => { + const { accessToken, accountId } = await setupUser(); + + const create = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Project" }, + }); + const projectId = create.json().id; + + // Create second account + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + // Try to update from second account + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}`, + headers: headers(accessToken, secondAccountId), + payload: { name: "Hacked" }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe("DELETE /projects/:id", () => { + it("deletes a project", async () => { + const { accessToken, accountId } = await setupUser(); + + const create = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "To Delete" }, + }); + const projectId = create.json().id; + + const res = await app.inject({ + method: "DELETE", + url: `/projects/${projectId}`, + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(204); + + // Verify it's gone + const list = await app.inject({ + method: "GET", + url: "/projects", + headers: headers(accessToken, accountId), + }); + expect(list.json()).toEqual([]); + }); + + it("returns 404 for non-existent project", async () => { + const { accessToken, accountId } = await setupUser(); + + const res = await app.inject({ + method: "DELETE", + url: "/projects/00000000-0000-0000-0000-000000000000", + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(404); + }); + + it("returns 404 when deleting a project from another account", async () => { + const { accessToken, accountId } = await setupUser(); + + const create = await app.inject({ + method: "POST", + url: "/projects", + headers: headers(accessToken, accountId), + payload: { name: "Project" }, + }); + const projectId = create.json().id; + + // Create second account + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + // Try to delete from second account + const res = await app.inject({ + method: "DELETE", + url: `/projects/${projectId}`, + headers: headers(accessToken, secondAccountId), + }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/tests/wms.test.ts b/tests/wms.test.ts new file mode 100644 index 0000000..5b2b23c --- /dev/null +++ b/tests/wms.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb, signupUser } from "./helpers.js"; +import { db } from "../src/db/index.js"; +import { packages } from "../src/db/schema.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +async function setupUserWithProject() { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken, accounts } = signup.json(); + const accountId = accounts[0].id as string; + + const projectRes = await app.inject({ + method: "POST", + url: "/projects", + headers: { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }, + payload: { name: "My Project" }, + }); + const projectId = projectRes.json().id as string; + + return { accessToken, accountId, projectId }; +} + +function headers(accessToken: string, accountId: string) { + return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }; +} + +async function seedSmallPackage() { + const [pkg] = await db + .insert(packages) + .values({ slug: "small", name: "Small", vcpu: 1, ram: 1024, disk: 20, priceMonthly: "5.00" }) + .returning(); + return pkg; +} + +describe("GET /projects/:projectId/wms", () => { + it("returns empty list when no WMs exist", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "GET", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + + it("returns WMs for the project", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM 1", vcpu: 1, ram: 512, disk: 10 }, + }); + await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM 2", vcpu: 2, ram: 1024, disk: 20 }, + }); + + const res = await app.inject({ + method: "GET", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(2); + }); + + it("returns 404 for a project in another account", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + // Create second account + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + const res = await app.inject({ + method: "GET", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, secondAccountId), + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe("POST /projects/:projectId/wms", () => { + it("creates a WM from a package", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + const pkg = await seedSmallPackage(); + + const res = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "My WM", packageId: pkg.id }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe("My WM"); + expect(body.packageId).toBe(pkg.id); + expect(body.vcpu).toBe(1); + expect(body.ram).toBe(1024); + expect(body.disk).toBe(20); + }); + + it("creates a WM with custom settings", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "Custom WM", vcpu: 4, ram: 8192, disk: 100 }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.packageId).toBeNull(); + expect(body.vcpu).toBe(4); + expect(body.ram).toBe(8192); + expect(body.disk).toBe(100); + }); + + it("returns 400 if neither packageId nor full custom settings provided", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "Bad WM", vcpu: 2 }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/packageId/); + }); + + it("returns 404 for non-existent package", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM", packageId: "00000000-0000-0000-0000-000000000000" }, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/Package/); + }); + + it("returns 404 for a project in another account", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + const res = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, secondAccountId), + payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe("PATCH /projects/:projectId/wms/:id", () => { + it("updates WM name", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const create = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "Old Name", vcpu: 1, ram: 512, disk: 10 }, + }); + const wmId = create.json().id; + + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken, accountId), + payload: { name: "New Name" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe("New Name"); + expect(res.json().vcpu).toBe(1); + }); + + it("updates individual settings (e.g. only disk)", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + const pkg = await seedSmallPackage(); + + const create = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM", packageId: pkg.id }, + }); + const wmId = create.json().id; + + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken, accountId), + payload: { disk: 40 }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().disk).toBe(40); + expect(res.json().vcpu).toBe(1); + expect(res.json().ram).toBe(1024); + }); + + it("returns 404 for non-existent WM", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken, accountId), + payload: { name: "Nope" }, + }); + + expect(res.statusCode).toBe(404); + }); + + it("returns 404 for project in another account", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const create = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, + }); + const wmId = create.json().id; + + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + const res = await app.inject({ + method: "PATCH", + url: `/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken, secondAccountId), + payload: { name: "Hacked" }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe("DELETE /projects/:projectId/wms/:id", () => { + it("deletes a WM", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const create = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "To Delete", vcpu: 1, ram: 512, disk: 10 }, + }); + const wmId = create.json().id; + + const res = await app.inject({ + method: "DELETE", + url: `/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(204); + + // Verify it's gone + const list = await app.inject({ + method: "GET", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + }); + expect(list.json()).toEqual([]); + }); + + it("returns 404 for non-existent WM", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const res = await app.inject({ + method: "DELETE", + url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken, accountId), + }); + + expect(res.statusCode).toBe(404); + }); + + it("returns 404 for project in another account", async () => { + const { accessToken, accountId, projectId } = await setupUserWithProject(); + + const create = await app.inject({ + method: "POST", + url: `/projects/${projectId}/wms`, + headers: headers(accessToken, accountId), + payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, + }); + const wmId = create.json().id; + + const accountRes = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + const secondAccountId = accountRes.json().id; + + const res = await app.inject({ + method: "DELETE", + url: `/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken, secondAccountId), + }); + + expect(res.statusCode).toBe(404); + }); +});