diff --git a/drizzle/0003_happy_starhawk.sql b/drizzle/0003_happy_starhawk.sql new file mode 100644 index 0000000..b5882c4 --- /dev/null +++ b/drizzle/0003_happy_starhawk.sql @@ -0,0 +1,10 @@ +CREATE TABLE `project` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `teamId` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`teamId`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_teamId_slug_unique` ON `project` (`teamId`,`slug`); \ No newline at end of file diff --git a/drizzle/0004_kind_vision.sql b/drizzle/0004_kind_vision.sql new file mode 100644 index 0000000..55f38d5 --- /dev/null +++ b/drizzle/0004_kind_vision.sql @@ -0,0 +1,4 @@ +ALTER TABLE `project` RENAME COLUMN "teamId" TO "team_id";--> statement-breakpoint +DROP INDEX `project_teamId_slug_unique`;--> statement-breakpoint +CREATE UNIQUE INDEX `project_team_id_slug_unique` ON `project` (`team_id`,`slug`);--> statement-breakpoint +ALTER TABLE `project` ALTER COLUMN "team_id" TO "team_id" text NOT NULL REFERENCES team(id) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..a7b5f43 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,939 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "da8b621a-993a-4666-8f9e-82c6eb31e682", + "prevId": "f5cd8a0b-5543-4feb-8447-f3805c5e78e7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "database": { + "name": "database", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S3'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_name": { + "name": "bucket_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_ciphertext": { + "name": "ak_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_iv": { + "name": "ak_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_tag": { + "name": "ak_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_ciphertext": { + "name": "sk_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_iv": { + "name": "sk_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_tag": { + "name": "sk_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "database_team_id_team_id_fk": { + "name": "database_team_id_team_id_fk", + "tableFrom": "database", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "database_created_by_user_id_fk": { + "name": "database_created_by_user_id_fk", + "tableFrom": "database", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "two_factor": { + "name": "two_factor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "team_slug_unique": { + "name": "team_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_member": { + "name": "team_member", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_member_user_id_team_id_pk": { + "columns": [ + "user_id", + "team_id" + ], + "name": "team_member_user_id_team_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roblox_credentials": { + "name": "roblox_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_ciphertext": { + "name": "key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_iv": { + "name": "key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_tag": { + "name": "key_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_owner_roblox_id": { + "name": "key_owner_roblox_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "roblox_credentials_team_id_team_id_fk": { + "name": "roblox_credentials_team_id_team_id_fk", + "tableFrom": "roblox_credentials", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roblox_credentials_created_by_user_id_fk": { + "name": "roblox_credentials_created_by_user_id_fk", + "tableFrom": "roblox_credentials", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project": { + "name": "project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "teamId": { + "name": "teamId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_teamId_slug_unique": { + "name": "project_teamId_slug_unique", + "columns": [ + "teamId", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_teamId_team_id_fk": { + "name": "project_teamId_team_id_fk", + "tableFrom": "project", + "tableTo": "team", + "columnsFrom": [ + "teamId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ 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..0bfa413 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,941 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "067296a2-71a3-4095-a021-ca14088381ff", + "prevId": "da8b621a-993a-4666-8f9e-82c6eb31e682", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "database": { + "name": "database", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S3'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_name": { + "name": "bucket_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_ciphertext": { + "name": "ak_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_iv": { + "name": "ak_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ak_tag": { + "name": "ak_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_ciphertext": { + "name": "sk_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_iv": { + "name": "sk_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sk_tag": { + "name": "sk_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "database_team_id_team_id_fk": { + "name": "database_team_id_team_id_fk", + "tableFrom": "database", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "database_created_by_user_id_fk": { + "name": "database_created_by_user_id_fk", + "tableFrom": "database", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "two_factor": { + "name": "two_factor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "team_slug_unique": { + "name": "team_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_member": { + "name": "team_member", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_member_user_id_team_id_pk": { + "columns": [ + "user_id", + "team_id" + ], + "name": "team_member_user_id_team_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roblox_credentials": { + "name": "roblox_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_ciphertext": { + "name": "key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_iv": { + "name": "key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_tag": { + "name": "key_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_owner_roblox_id": { + "name": "key_owner_roblox_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "roblox_credentials_team_id_team_id_fk": { + "name": "roblox_credentials_team_id_team_id_fk", + "tableFrom": "roblox_credentials", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roblox_credentials_created_by_user_id_fk": { + "name": "roblox_credentials_created_by_user_id_fk", + "tableFrom": "roblox_credentials", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project": { + "name": "project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_team_id_slug_unique": { + "name": "project_team_id_slug_unique", + "columns": [ + "team_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_team_id_team_id_fk": { + "name": "project_team_id_team_id_fk", + "tableFrom": "project", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"project\".\"teamId\"": "\"project\".\"team_id\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ad25fee..d63b919 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1774631008429, "tag": "0002_daffy_wolf_cub", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1775382237392, + "tag": "0003_happy_starhawk", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1775383387755, + "tag": "0004_kind_vision", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/teams/[teamId]/projects/[projectId]/route.ts b/src/app/api/teams/[teamId]/projects/[projectId]/route.ts new file mode 100644 index 0000000..d8b0a33 --- /dev/null +++ b/src/app/api/teams/[teamId]/projects/[projectId]/route.ts @@ -0,0 +1,137 @@ +import { auth } from "@/src/lib/auth"; +import { RenameProjectSchema } from "@/src/lib/types/project-types"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { ProjectService } from "@/src/services/ProjectService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import z, { ZodError } from "zod"; + +interface Context { + params: Promise<{ + teamId: string; + projectId: string; + }>; +} + +export async function GET(_: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const projectId = params.projectId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + if (!projectId) { + return NextResponse.json( + { error: "Project ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const project = await ProjectService.GetProject( + session.user.id, + teamId, + projectId, + ); + return NextResponse.json(project); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function PATCH(req: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const projectId = params.projectId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + if (!projectId) { + return NextResponse.json( + { error: "Project ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + try { + const validatedData = RenameProjectSchema.parse(body); + const updatedProject = await ProjectService.RenameProject( + session.user.id, + teamId, + projectId, + validatedData.name, + ); + return NextResponse.json(updatedProject); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return ErrorToNextResponse(error); + } +} + +export async function DELETE(_: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const projectId = params.projectId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + if (!projectId) { + return NextResponse.json( + { error: "Project ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const deletedProject = await ProjectService.DeleteProject( + session.user.id, + teamId, + projectId, + ); + return NextResponse.json(deletedProject); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/projects/resolve-slug/[slug]/route.ts b/src/app/api/teams/[teamId]/projects/resolve-slug/[slug]/route.ts new file mode 100644 index 0000000..10d196b --- /dev/null +++ b/src/app/api/teams/[teamId]/projects/resolve-slug/[slug]/route.ts @@ -0,0 +1,45 @@ +import { auth } from "@/src/lib/auth"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { ProjectService } from "@/src/services/ProjectService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + slug: string; + }>; +} + +export async function GET(_: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const slug = params.slug; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + if (!slug) { + return NextResponse.json({ error: "Slug is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const data = await ProjectService.GetProjectBySlug( + session.user.id, + teamId, + slug, + ); + return NextResponse.json(data); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/projects/route.ts b/src/app/api/teams/[teamId]/projects/route.ts new file mode 100644 index 0000000..a7635f5 --- /dev/null +++ b/src/app/api/teams/[teamId]/projects/route.ts @@ -0,0 +1,82 @@ +import { auth } from "@/src/lib/auth"; +import { CreateProjectSchema } from "@/src/lib/types/project-types"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { ProjectService } from "@/src/services/ProjectService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import z, { ZodError } from "zod"; + +interface Context { + params: Promise<{ + teamId: string; + }>; +} + +export async function GET(_: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const projects = await ProjectService.ListTeamProjects( + session.user.id, + teamId, + ); + return NextResponse.json(projects); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function POST(req: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + try { + const validatedData = CreateProjectSchema.parse(body); + const newProject = await ProjectService.CreateProject( + session.user.id, + teamId, + validatedData.name, + ); + return NextResponse.json(newProject); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return ErrorToNextResponse(error); + } +} diff --git a/src/app/dashboard/[teamSlug]/components/CreateProjectDialog.tsx b/src/app/dashboard/[teamSlug]/components/CreateProjectDialog.tsx new file mode 100644 index 0000000..e1f9c81 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/components/CreateProjectDialog.tsx @@ -0,0 +1,71 @@ +import FormDialog from "@/src/components/FormDialog"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form"; +import { Input } from "@/src/components/ui/input"; +import { useProjectMutations } from "@/src/hooks/useProject"; +import { useTeam } from "@/src/hooks/useTeam"; +import { CreateProjectSchema } from "@/src/lib/types/project-types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +interface CreateProjectDialogProps { + open: boolean; + setIsOpen: (open: boolean) => void; +} + +export default function CreateProjectDialog({ open, setIsOpen }: CreateProjectDialogProps) { + const { data: team } = useTeam(); + const { createProject } = useProjectMutations(); + + const form = useForm({ + resolver: zodResolver(CreateProjectSchema), + defaultValues: { name: "" }, + }); + + const handleCreate = async (name: string) => { + if (!team) return; + const toastId = toast.loading("Creating project..."); + createProject + .mutateAsync({ teamId: team.id, name }) + .then(() => { + toast.success("Project created!", { id: toastId }); + setIsOpen(false); + form.reset(); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id: toastId }); + } else { + toast.error( + "An unknown error occurred while creating the project. Please try again later.", + { id: toastId }, + ); + } + }); + }; + + return ( + handleCreate(name)} + > + ( + + Name + + + + + + )} + /> + + ); +} diff --git a/src/app/dashboard/[teamSlug]/components/ProjectColumn.tsx b/src/app/dashboard/[teamSlug]/components/ProjectColumn.tsx new file mode 100644 index 0000000..154bd58 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/components/ProjectColumn.tsx @@ -0,0 +1,52 @@ +"use client"; +import LocalTime from "@/src/components/LocalTime"; +import { Button } from "@/src/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/src/components/ui/dropdown-menu"; +import { Project } from "@/src/lib/types/project-types"; +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal } from "lucide-react"; + +const ProjectActions = ({ project }: { project: Project }) => { + return ( +
e.stopPropagation()}> + + + + + + navigator.clipboard.writeText(project.id)}> + + Copy Project ID + + + +
+ ); +}; + +export const projectColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Project", + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ getValue }) => { + const date = new Date(getValue()); + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => , + }, +]; diff --git a/src/app/dashboard/[teamSlug]/page.tsx b/src/app/dashboard/[teamSlug]/page.tsx index 0b945fd..2bf1ce4 100644 --- a/src/app/dashboard/[teamSlug]/page.tsx +++ b/src/app/dashboard/[teamSlug]/page.tsx @@ -1,11 +1,59 @@ "use client"; +import { DataTable } from "@/src/components/DataTable"; +import { Button } from "@/src/components/ui/button"; +import { useProjects } from "@/src/hooks/useProject"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import CreateProjectDialog from "./components/CreateProjectDialog"; +import { projectColumns } from "./components/ProjectColumn"; import { useTeam } from "@/src/hooks/useTeam"; +import { hasPermission } from "@/src/lib/utils/team-utils"; +import { useParams, useRouter } from "next/navigation"; +import { Project } from "@/src/lib/types/project-types"; + +const CreateProjectButton = () => { + const [open, setIsOpen] = useState(false); + const { data: team } = useTeam(); + return ( + <> + + + + ); +}; export default function Page() { - const { data, isLoading } = useTeam(); - if (isLoading) { - return Loading...; - } + const { data, isLoading } = useProjects(); + const { teamSlug } = useParams(); + const router = useRouter(); + + const handleRowClick = (row: { original: Project }) => { + router.push(`/dashboard/${teamSlug}/projects/${row.original.slug}`); + }; - return {data?.name}; + return ( + <> + Projects +
+ } + onRowClick={handleRowClick} + /> +
+ + ); } diff --git a/src/app/dashboard/[teamSlug]/projects/[projectSlug]/page.tsx b/src/app/dashboard/[teamSlug]/projects/[projectSlug]/page.tsx new file mode 100644 index 0000000..ceb00d9 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/projects/[projectSlug]/page.tsx @@ -0,0 +1,15 @@ +"use client"; +import { useProject } from "@/src/hooks/useProject"; + +export default function Page() { + const { data: project, isLoading } = useProject(); + + if (isLoading) return null; + + return ( +
+

{project?.name}

+

{project?.slug}

+
+ ); +} diff --git a/src/controllers/ProjectController.ts b/src/controllers/ProjectController.ts new file mode 100644 index 0000000..e4ca844 --- /dev/null +++ b/src/controllers/ProjectController.ts @@ -0,0 +1,30 @@ +import { Project } from "../lib/types/project-types"; +import { fetcher } from "../lib/utils/api-utils"; + +export const ProjectController = { + list: (teamId: string) => + fetcher(`/api/teams/${teamId}/projects`), + + resolve: (teamId: string, slug: string) => + fetcher(`/api/teams/${teamId}/projects/resolve-slug/${slug}`), + + get: (teamId: string, projectId: string) => + fetcher(`/api/teams/${teamId}/projects/${projectId}`), + + create: (teamId: string, name: string) => + fetcher(`/api/teams/${teamId}/projects`, { + method: "POST", + body: JSON.stringify({ name }), + }), + + rename: (teamId: string, projectId: string, newName: string) => + fetcher(`/api/teams/${teamId}/projects/${projectId}`, { + method: "PATCH", + body: JSON.stringify({ name: newName }), + }), + + delete: (teamId: string, projectId: string) => + fetcher(`/api/teams/${teamId}/projects/${projectId}`, { + method: "DELETE", + }), +}; diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 2b31b5f..efbc84f 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -7,3 +7,4 @@ export * from "./database"; export * from "./team"; export * from "./team_member"; export * from "./roblox_credentials"; +export * from "./project"; diff --git a/src/db/schema/project.ts b/src/db/schema/project.ts new file mode 100644 index 0000000..1247c1e --- /dev/null +++ b/src/db/schema/project.ts @@ -0,0 +1,22 @@ +import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; +import { team } from "./team"; + +export const project = sqliteTable( + "project", + { + id: text("id").primaryKey(), + + name: text("name").notNull(), + slug: text("slug").notNull(), + + teamId: text("team_id") + .notNull() + .references(() => team.id, { onDelete: "cascade" }), + + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (t) => [unique().on(t.teamId, t.slug)], +); diff --git a/src/hooks/useProject.ts b/src/hooks/useProject.ts new file mode 100644 index 0000000..a5042b2 --- /dev/null +++ b/src/hooks/useProject.ts @@ -0,0 +1,113 @@ +"use client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useTeam } from "./useTeam"; +import { Project } from "../lib/types/project-types"; +import { ProjectController } from "../controllers/ProjectController"; + +export function useProject() { + const { teamSlug, projectSlug } = useParams(); + const { data: team } = useTeam(); + const router = useRouter(); + + const query = useQuery({ + queryKey: ["project", team?.id, projectSlug], + queryFn: () => ProjectController.resolve(team!.id, projectSlug as string), + enabled: !!team?.id && !!projectSlug, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + useEffect(() => { + if (query.isError) { + router.push(`/dashboard/${teamSlug}`); + } + }, [query.isError, router, teamSlug]); + + return query; +} + +export function useProjects() { + const { + data: team, + isLoading: isLoadingTeam, + isError: isErrorTeam, + } = useTeam(); + + const query = useQuery({ + queryKey: ["projects", team?.id], + queryFn: () => ProjectController.list(team!.id), + enabled: !!team?.id, + staleTime: 5 * 60 * 1000, + }); + + const isLoading = isLoadingTeam || query.isLoading; + const isError = isErrorTeam || query.isError; + + return { + ...query, + isLoading, + isError, + }; +} + +export function useProjectMutations() { + const queryClient = useQueryClient(); + + const createProject = useMutation({ + mutationFn: ({ teamId, name }: { teamId: string; name: string }) => + ProjectController.create(teamId, name), + onSuccess: (project, variables) => { + queryClient.setQueryData( + ["projects", variables.teamId], + (prevData) => { + if (!prevData) return [project]; + return [...prevData, project]; + }, + ); + }, + }); + + const deleteProject = useMutation({ + mutationFn: ({ + teamId, + projectId, + }: { + teamId: string; + projectId: string; + }) => ProjectController.delete(teamId, projectId), + onSuccess: (project, variables) => { + queryClient.setQueryData( + ["projects", variables.teamId], + (prevData) => { + if (!prevData) return prevData; + return prevData.filter((p) => p.id !== project.id); + }, + ); + }, + }); + + const renameProject = useMutation({ + mutationFn: ({ + teamId, + projectId, + newName, + }: { + teamId: string; + projectId: string; + newName: string; + }) => ProjectController.rename(teamId, projectId, newName), + onSuccess: (project, variables) => { + queryClient.setQueryData( + ["projects", variables.teamId], + (prevData) => { + if (!prevData) return [project]; + return prevData.map((p) => (p.id === project.id ? project : p)); + }, + ); + }, + }); + + return { createProject, deleteProject, renameProject }; +} diff --git a/src/lib/types/project-types.ts b/src/lib/types/project-types.ts new file mode 100644 index 0000000..f88f0f6 --- /dev/null +++ b/src/lib/types/project-types.ts @@ -0,0 +1,26 @@ +import { project } from "@/src/db/schema"; +import { InferDrizzleSelect } from "../utils"; +import z from "zod"; + +export const ProjectSelect = { + id: project.id, + name: project.name, + slug: project.slug, + teamId: project.teamId, + createdAt: project.createdAt, +}; +export type Project = InferDrizzleSelect; + +export const CreateProjectSchema = z.object({ + name: z + .string() + .min(1, { error: "Project name must be at least 1 character" }) + .max(64, { error: "Project name must be at most 64 characters" }), +}); + +export const RenameProjectSchema = z.object({ + name: z + .string() + .min(1, { error: "Project name must be at least 1 character" }) + .max(64, { error: "Project name must be at most 64 characters" }), +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5d0e9a9..cf4abc4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,3 +14,12 @@ export type InferDrizzleSelect = { : TData | null : never; }; + +export function nameToSlug(name: string) { + return name + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") // Remove special characters + .trim() + .split(/\s+/) // Remove white spaces + .join("-"); // Join the words with a dash +} diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts index 10ea171..f147b0c 100644 --- a/src/lib/utils/team-utils.ts +++ b/src/lib/utils/team-utils.ts @@ -29,6 +29,11 @@ export const TEAM_PERMISSIONS = { RenameRobloxCredential: ROLES_RANK.admin, RotateRobloxCredential: ROLES_RANK.admin, RefreshRobloxCredential: ROLES_RANK.admin, + + ListProjects: ROLES_RANK.viewer, + CreateProject: ROLES_RANK.developer, + DeleteProject: ROLES_RANK.admin, + RenameProject: ROLES_RANK.admin, } as const satisfies Record; export type TeamAction = keyof typeof TEAM_PERMISSIONS; diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts new file mode 100644 index 0000000..eba2ce1 --- /dev/null +++ b/src/services/ProjectService.ts @@ -0,0 +1,202 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db"; +import { project } from "../db/schema"; +import { randomUUID } from "crypto"; +import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; +import { Project, ProjectSelect } from "../lib/types/project-types"; +import { TeamService } from "./TeamService"; +import { hasPermission } from "../lib/utils/team-utils"; +import { nameToSlug } from "../lib/utils"; + +const ProjectNotFound = new ApiError( + 404, + "ProjectNotFound", + "The requested project was not found", +); +const InvalidProjectName = new ApiError( + 400, + "InvalidProjectName", + "The given project name is invalid. Make sure it includes at least 1 character and at most 64 characters", +); +const ProjectSlugTaken = new ApiError( + 409, + "ProjectSlugTaken", + "A project with this name already exists in the team", +); + +export const ProjectService = { + // Private Methods + ValidateProjectName(name: string) { + if (name.length < 1 || name.length > 64) { + throw InvalidProjectName; + } + }, + + // Public Methods + + async CreateProject( + actorId: string, + teamId: string, + name: string, + ): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "CreateProject")) { + throw AccessDenied; + } + + name = name.trim(); + this.ValidateProjectName(name); + const slug = nameToSlug(name); + this.ValidateProjectName(slug); + + try { + const [newProject] = await db + .insert(project) + .values({ + id: randomUUID(), + name, + slug, + teamId, + }) + .returning(ProjectSelect); + return newProject; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + throw ProjectSlugTaken; + } + throw DatabaseError; + } + }, + + async DeleteProject( + actorId: string, + teamId: string, + projectId: string, + ): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "DeleteProject")) { + throw AccessDenied; + } + + try { + const [result] = await db + .delete(project) + .where(and(eq(project.id, projectId), eq(project.teamId, teamId))) + .returning(ProjectSelect); + if (!result) throw AccessDenied; + return result; + } catch { + throw DatabaseError; + } + }, + + async RenameProject( + actorId: string, + teamId: string, + projectId: string, + newName: string, + ): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "RenameProject")) { + throw AccessDenied; + } + + newName = newName.trim(); + this.ValidateProjectName(newName); + const slug = nameToSlug(newName); + this.ValidateProjectName(slug); + + try { + const [result] = await db + .update(project) + .set({ name: newName, slug }) + .where(and(eq(project.id, projectId), eq(project.teamId, teamId))) + .returning(ProjectSelect); + if (!result) throw AccessDenied; + return result; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + throw ProjectSlugTaken; + } + throw DatabaseError; + } + }, + + async GetProject( + actorId: string, + teamId: string, + projectId: string, + ): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "ListProjects")) { + throw AccessDenied; + } + + let result; + try { + [result] = await db + .select(ProjectSelect) + .from(project) + .where(and(eq(project.id, projectId), eq(project.teamId, teamId))) + .limit(1); + } catch { + throw DatabaseError; + } + + if (!result) { + throw ProjectNotFound; + } + + return result; + }, + + async GetProjectBySlug( + actorId: string, + teamId: string, + projectSlug: string, + ): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "ListProjects")) { + throw AccessDenied; + } + + let result; + try { + [result] = await db + .select(ProjectSelect) + .from(project) + .where(and(eq(project.slug, projectSlug), eq(project.teamId, teamId))) + .limit(1); + } catch { + throw DatabaseError; + } + + if (!result) { + throw ProjectNotFound; + } + + return result; + }, + + async ListTeamProjects(actorId: string, teamId: string): Promise { + const role = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(role, "ListProjects")) { + throw AccessDenied; + } + + try { + return await db + .select(ProjectSelect) + .from(project) + .where(eq(project.teamId, teamId)); + } catch { + throw DatabaseError; + } + }, +}; diff --git a/src/services/TeamService.ts b/src/services/TeamService.ts index cfd0812..23b4830 100644 --- a/src/services/TeamService.ts +++ b/src/services/TeamService.ts @@ -22,6 +22,7 @@ import { UserTeam, UserTeamSelect, } from "../lib/types/team-types"; +import { nameToSlug } from "../lib/utils"; const InvalidSlug = new ApiError( 409, @@ -68,11 +69,7 @@ const InvalidRole = new ApiError( export const TeamService = { // Private Methods CreateSlugFromName(name: string) { - const cleanName = name - .toLowerCase() - .replace(/[^a-z0-9\s]/g, "") // Remove special characters - .split(/\s+/) // Remove white spaces - .join("-"); // Join the words with a dash + const cleanName = nameToSlug(name); if (cleanName.length < 3 || cleanName.length > 32) { throw InvalidName;