diff --git a/.env.example b/.env.example index 6517370..49516fd 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,6 @@ SENTRY_AUTH_TOKEN=changeme # AIRTABLE_TOKEN=abcxyz # optional -PRINTABLES_ALLOWED_LICENSES_ID=7,1,2,9,12,10,11 # License IDs from printables \ No newline at end of file +PRINTABLES_ALLOWED_LICENSES_ID=7,1,2,9,12,10,11 # License IDs from printables + +CLUBS_API_KEY=changeme # API key for clubs.hackclub.com \ No newline at end of file diff --git a/drizzle/0028_illegal_robbie_robertson.sql b/drizzle/0028_illegal_robbie_robertson.sql new file mode 100644 index 0000000..d6c2243 --- /dev/null +++ b/drizzle/0028_illegal_robbie_robertson.sql @@ -0,0 +1,22 @@ +CREATE TYPE "public"."club_role" AS ENUM('leader', 'member');--> statement-breakpoint +CREATE TABLE "club" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "joinCode" text, + "createdAt" timestamp DEFAULT now(), + CONSTRAINT "club_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "club_membership" ( + "id" serial PRIMARY KEY NOT NULL, + "clubId" integer NOT NULL, + "userId" integer NOT NULL, + "role" "club_role" NOT NULL, + "createdAt" timestamp DEFAULT now(), + CONSTRAINT "club_membership_userId_unique" UNIQUE("userId") +); +--> statement-breakpoint +ALTER TABLE "ship" ADD COLUMN "clubId" integer;--> statement-breakpoint +ALTER TABLE "club_membership" ADD CONSTRAINT "club_membership_clubId_club_id_fk" FOREIGN KEY ("clubId") REFERENCES "public"."club"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "club_membership" ADD CONSTRAINT "club_membership_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ship" ADD CONSTRAINT "ship_clubId_club_id_fk" FOREIGN KEY ("clubId") REFERENCES "public"."club"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0028_snapshot.json b/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..23417f6 --- /dev/null +++ b/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1407 @@ +{ + "id": "fabc3135-7dc2-4e68-bd11-f63e1d94b5dc", + "prevId": "1843b2fe-285f-4ca2-9169-6869508c379d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.club": { + "name": "club", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joinCode": { + "name": "joinCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "club_name_unique": { + "name": "club_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.club_membership": { + "name": "club_membership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "clubId": { + "name": "clubId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "club_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "club_membership_clubId_club_id_fk": { + "name": "club_membership_clubId_club_id_fk", + "tableFrom": "club_membership", + "tableTo": "club", + "columnsFrom": [ + "clubId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "club_membership_userId_user_id_fk": { + "name": "club_membership_userId_user_id_fk", + "tableFrom": "club_membership", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "club_membership_userId_unique": { + "name": "club_membership_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devlog": { + "name": "devlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeSpent": { + "name": "timeSpent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devlog_userId_user_id_fk": { + "name": "devlog_userId_user_id_fk", + "tableFrom": "devlog", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "devlog_projectId_project_id_fk": { + "name": "devlog_projectId_project_id_fk", + "tableFrom": "devlog", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonate_audit_log": { + "name": "impersonate_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "adminUserId": { + "name": "adminUserId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "targetUserId": { + "name": "targetUserId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "impersonate_audit_log_adminUserId_user_id_fk": { + "name": "impersonate_audit_log_adminUserId_user_id_fk", + "tableFrom": "impersonate_audit_log", + "tableTo": "user", + "columnsFrom": [ + "adminUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "impersonate_audit_log_targetUserId_user_id_fk": { + "name": "impersonate_audit_log_targetUserId_user_id_fk", + "tableFrom": "impersonate_audit_log", + "tableTo": "user", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legion_review": { + "name": "legion_review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filamentUsed": { + "name": "filamentUsed", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "legion_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legion_review_userId_user_id_fk": { + "name": "legion_review_userId_user_id_fk", + "tableFrom": "legion_review", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "legion_review_projectId_project_id_fk": { + "name": "legion_review_projectId_project_id_fk", + "tableFrom": "legion_review", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.market_item": { + "name": "market_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "minRequiredShopScore": { + "name": "minRequiredShopScore", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "minShopScore": { + "name": "minShopScore", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "maxShopScore": { + "name": "maxShopScore", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "maxPrice": { + "name": "maxPrice", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minPrice": { + "name": "minPrice", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "market_item_createdBy_user_id_fk": { + "name": "market_item_createdBy_user_id_fk", + "tableFrom": "market_item", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.market_item_order": { + "name": "market_item_order", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "marketItemId": { + "name": "marketItemId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "addressId": { + "name": "addressId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bricksPaid": { + "name": "bricksPaid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "market_order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_approval'" + }, + "userNotes": { + "name": "userNotes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "market_item_order_userId_user_id_fk": { + "name": "market_item_order_userId_user_id_fk", + "tableFrom": "market_item_order", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "market_item_order_marketItemId_market_item_id_fk": { + "name": "market_item_order_marketItemId_market_item_id_fk", + "tableFrom": "market_item_order", + "tableTo": "market_item", + "columnsFrom": [ + "marketItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ovenpheus_log": { + "name": "ovenpheus_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "clay": { + "name": "clay", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "bricksReceived": { + "name": "bricksReceived", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ovenpheus_log_userId_user_id_fk": { + "name": "ovenpheus_log_userId_user_id_fk", + "tableFrom": "ovenpheus_log", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "editorFileType": { + "name": "editorFileType", + "type": "editor_file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "editorUrl": { + "name": "editorUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploadedFileUrl": { + "name": "uploadedFileUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modelFile": { + "name": "modelFile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'building'" + }, + "printedBy": { + "name": "printedBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "submittedToAirtable": { + "name": "submittedToAirtable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_userId_user_id_fk": { + "name": "project_userId_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_printedBy_user_id_fk": { + "name": "project_printedBy_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "printedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ship": { + "name": "ship", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "clubId": { + "name": "clubId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "editorFileType": { + "name": "editorFileType", + "type": "editor_file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "editorUrl": { + "name": "editorUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploadedFileUrl": { + "name": "uploadedFileUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modelFile": { + "name": "modelFile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ship_userId_user_id_fk": { + "name": "ship_userId_user_id_fk", + "tableFrom": "ship", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ship_projectId_project_id_fk": { + "name": "ship_projectId_project_id_fk", + "tableFrom": "ship", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ship_clubId_club_id_fk": { + "name": "ship_clubId_club_id_fk", + "tableFrom": "ship", + "tableTo": "club", + "columnsFrom": [ + "clubId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.t1_review": { + "name": "t1_review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "t1_review_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "t1_review_userId_user_id_fk": { + "name": "t1_review_userId_user_id_fk", + "tableFrom": "t1_review", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "t1_review_projectId_project_id_fk": { + "name": "t1_review_projectId_project_id_fk", + "tableFrom": "t1_review", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.t2_review": { + "name": "t2_review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shopScoreMultiplier": { + "name": "shopScoreMultiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "t2_review_userId_user_id_fk": { + "name": "t2_review_userId_user_id_fk", + "tableFrom": "t2_review", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "t2_review_projectId_project_id_fk": { + "name": "t2_review_projectId_project_id_fk", + "tableFrom": "t2_review", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "idvId": { + "name": "idvId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idvToken": { + "name": "idvToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slackId": { + "name": "slackId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profilePicture": { + "name": "profilePicture", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hackatimeTrust": { + "name": "hackatimeTrust", + "type": "hackatime_trust", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trust": { + "name": "trust", + "type": "trust", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'blue'" + }, + "clay": { + "name": "clay", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "brick": { + "name": "brick", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "shopScore": { + "name": "shopScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hasBasePrinter": { + "name": "hasBasePrinter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasT1Review": { + "name": "hasT1Review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasT2Review": { + "name": "hasT2Review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasAdmin": { + "name": "hasAdmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isPrinter": { + "name": "isPrinter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "referralId": { + "name": "referralId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastLoginAt": { + "name": "lastLoginAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_idvId_unique": { + "name": "user_idvId_unique", + "nullsNotDistinct": false, + "columns": [ + "idvId" + ] + }, + "user_slackId_unique": { + "name": "user_slackId_unique", + "nullsNotDistinct": false, + "columns": [ + "slackId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.club_role": { + "name": "club_role", + "schema": "public", + "values": [ + "leader", + "member" + ] + }, + "public.editor_file_type": { + "name": "editor_file_type", + "schema": "public", + "values": [ + "url", + "upload" + ] + }, + "public.hackatime_trust": { + "name": "hackatime_trust", + "schema": "public", + "values": [ + "green", + "blue", + "yellow", + "red" + ] + }, + "public.legion_action": { + "name": "legion_action", + "schema": "public", + "values": [ + "mark_for_printing", + "unmark_for_printing", + "print", + "add_comment", + "reject", + "already_printed" + ] + }, + "public.market_order_status": { + "name": "market_order_status", + "schema": "public", + "values": [ + "awaiting_approval", + "fulfilled", + "denied", + "refunded" + ] + }, + "public.project_audit_log_type": { + "name": "project_audit_log_type", + "schema": "public", + "values": [ + "create", + "update", + "delete" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "building", + "submitted", + "t1_approved", + "printing", + "printed", + "t2_approved", + "finalized", + "rejected", + "rejected_locked" + ] + }, + "public.t1_review_action": { + "name": "t1_review_action", + "schema": "public", + "values": [ + "approve", + "approve_no_print", + "add_comment", + "reject", + "reject_lock" + ] + }, + "public.trust": { + "name": "trust", + "schema": "public", + "values": [ + "green", + "blue", + "yellow", + "red" + ] + } + }, + "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 70c211b..8c06a02 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1768175514843, "tag": "0027_motionless_colossus", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1769368148290, + "tag": "0028_illegal_robbie_robertson", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/clubs-api.ts b/src/lib/server/clubs-api.ts new file mode 100644 index 0000000..b1255e2 --- /dev/null +++ b/src/lib/server/clubs-api.ts @@ -0,0 +1,91 @@ +import { env } from '$env/dynamic/private'; + +const BASE_URL = 'https://clubapi.hackclub.com'; + +function getHeaders(): HeadersInit { + return { + Authorization: env.CLUBS_API_KEY ?? '' + }; +} + +export async function getLeaderClub( + slackId: string +): Promise<{ clubName: string; clubStatus: string } | null> { + try { + const response = await fetch( + `${BASE_URL}/leader/slack?slackid=${encodeURIComponent(slackId)}`, + { + headers: getHeaders() + } + ); + + if (!response.ok) { + console.error(`getLeaderClub failed: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + + if (data.club_name) { + return { + clubName: data.club_name, + clubStatus: data.club_status ?? '' + }; + } + + return null; + } catch (error) { + console.error('getLeaderClub error:', error); + return null; + } +} + +export async function verifyJoinCode(code: string): Promise<{ clubName: string } | null> { + try { + const response = await fetch(`${BASE_URL}/club/code?code=${encodeURIComponent(code)}`, { + headers: getHeaders() + }); + + if (!response.ok) { + console.error(`verifyJoinCode failed: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + + if (data.club_name) { + return { + clubName: data.club_name + }; + } + + return null; + } catch (error) { + console.error('verifyJoinCode error:', error); + return null; + } +} + +export async function getClubMembers(clubName: string): Promise { + try { + const response = await fetch(`${BASE_URL}/members?club_name=${encodeURIComponent(clubName)}`, { + headers: getHeaders() + }); + + if (!response.ok) { + console.error(`getClubMembers failed: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + + if (data.members && Array.isArray(data.members)) { + return data.members; + } + + return null; + } catch (error) { + console.error('getClubMembers error:', error); + return null; + } +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index ec7afc7..90e39ec 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -11,6 +11,7 @@ import { export const hackatimeTrustEnum = pgEnum('hackatime_trust', ['green', 'blue', 'yellow', 'red']); export const trustEnum = pgEnum('trust', ['green', 'blue', 'yellow', 'red']); +export const clubRoleEnum = pgEnum('club_role', ['leader', 'member']); export const user = pgTable('user', { id: serial().primaryKey(), // User ID @@ -53,6 +54,26 @@ export const session = pgTable('session', { expiresAt: timestamp().notNull() }); +export const club = pgTable('club', { + id: serial().primaryKey(), + name: text().notNull().unique(), + joinCode: text(), + createdAt: timestamp().defaultNow() +}); + +export const clubMembership = pgTable('club_membership', { + id: serial().primaryKey(), + clubId: integer() + .notNull() + .references(() => club.id), + userId: integer() + .notNull() + .unique() + .references(() => user.id), + role: clubRoleEnum().notNull(), + createdAt: timestamp().defaultNow() +}); + export const projectStatusEnum = pgEnum('status', [ 'building', 'submitted', @@ -109,6 +130,7 @@ export const ship = pgTable('ship', { projectId: integer() .notNull() .references(() => project.id), + clubId: integer().references(() => club.id), url: text().notNull(), @@ -326,3 +348,5 @@ export type LegionReview = typeof legionReview.$inferSelect; export type T2Review = typeof t2Review.$inferSelect; export type MarketItem = typeof marketItem.$inferSelect; +export type Club = typeof club.$inferSelect; +export type ClubMembership = typeof clubMembership.$inferSelect; diff --git a/src/routes/dashboard/Sidebar.svelte b/src/routes/dashboard/Sidebar.svelte index be067c1..d319942 100644 --- a/src/routes/dashboard/Sidebar.svelte +++ b/src/routes/dashboard/Sidebar.svelte @@ -8,12 +8,9 @@ ClipboardPen, ClipboardPenLine, Store, - ShieldUser, - - Box - - + Box, + Users } from '@lucide/svelte'; import { page } from '$app/state'; import logo from '$lib/assets/logo.png'; @@ -36,6 +33,7 @@ Projects Explore Market + Club {#if user.isPrinter} Print diff --git a/src/routes/dashboard/admin/ysws-review/[id]/+page.server.ts b/src/routes/dashboard/admin/ysws-review/[id]/+page.server.ts index 3494c58..63b48b4 100644 --- a/src/routes/dashboard/admin/ysws-review/[id]/+page.server.ts +++ b/src/routes/dashboard/admin/ysws-review/[id]/+page.server.ts @@ -1,5 +1,5 @@ import { db } from '$lib/server/db/index.js'; -import { project, user, devlog, t2Review, legionReview } from '$lib/server/db/schema.js'; +import { project, user, devlog, t2Review, legionReview, ship } from '$lib/server/db/schema.js'; import { error, fail, redirect } from '@sveltejs/kit'; import { eq, and, asc, sql, desc } from 'drizzle-orm'; import type { Actions } from './$types'; @@ -334,33 +334,62 @@ export const actions = { .where(eq(user.id, locals.user.id)); } - if (queriedProject.user && queriedProject.project.status === 'printed') { - const payouts = calculatePayouts( - queriedProject.timeSpent, - await getLatestPrintFilament(id), - parsedShopScoreMultiplier, - queriedProject.user.hasBasePrinter, - queriedProject.project.createdAt - ); + if (queriedProject.user) { + const [latestShip] = await db + .select({ clubId: ship.clubId }) + .from(ship) + .where(eq(ship.projectId, id)) + .orderBy(desc(ship.timestamp)) + .limit(1); + + const isClubShip = latestShip?.clubId !== null && latestShip?.clubId !== undefined; + + if (!isClubShip) { + const payouts = calculatePayouts( + queriedProject.timeSpent, + await getLatestPrintFilament(id), + parsedShopScoreMultiplier, + queriedProject.user.hasBasePrinter, + queriedProject.project.createdAt + ); + + await db + .update(user) + .set({ + clay: sql`${user.clay} + ${payouts.clay ?? 0}`, + brick: sql`${user.brick} + ${payouts.bricks ?? 0}`, + shopScore: sql`${user.shopScore} + ${payouts.shopScore}` + }) + .where(eq(user.id, queriedProject.user.id)); + } + if (queriedProject.user && queriedProject.project.status === 'printed') { + const payouts = calculatePayouts( + queriedProject.timeSpent, + await getLatestPrintFilament(id), + parsedShopScoreMultiplier, + queriedProject.user.hasBasePrinter, + queriedProject.project.createdAt + ); + + await db + .update(user) + .set({ + clay: sql`${user.clay} + ${payouts.clay ?? 0}`, + brick: sql`${user.brick} + ${payouts.bricks ?? 0}`, + shopScore: sql`${user.shopScore} + ${payouts.shopScore}` + }) + .where(eq(user.id, queriedProject.user.id)); - await db - .update(user) - .set({ - clay: sql`${user.clay} + ${payouts.clay ?? 0}`, - brick: sql`${user.brick} + ${payouts.bricks ?? 0}`, - shopScore: sql`${user.shopScore} + ${payouts.shopScore}` - }) - .where(eq(user.id, queriedProject.user.id)); + const feedbackText = feedback ? `\n\nHere's what they said:\n${feedback}` : ''; - const feedbackText = feedback ? `\n\nHere's what they said:\n${feedback}` : ''; + await sendSlackDM( + queriedProject.user.slackId, + `Your project has been ${statusMessage}${feedbackText}` + ); + } - await sendSlackDM( - queriedProject.user.slackId, - `Your project has been ${statusMessage}${feedbackText}` - ); + return redirect(302, '/dashboard/admin/ysws-review'); } - - return redirect(302, '/dashboard/admin/ysws-review'); }, override: async ({ locals, request, params }) => { diff --git a/src/routes/dashboard/clubs/+page.server.ts b/src/routes/dashboard/clubs/+page.server.ts new file mode 100644 index 0000000..1221e86 --- /dev/null +++ b/src/routes/dashboard/clubs/+page.server.ts @@ -0,0 +1,218 @@ +import { db } from '$lib/server/db/index.js'; +import { club, clubMembership, user, devlog, ship, project } from '$lib/server/db/schema.js'; +import { error, fail } from '@sveltejs/kit'; +import { eq, and, sql, inArray } from 'drizzle-orm'; +import { getLeaderClub } from '$lib/server/clubs-api.js'; +import type { Actions } from './$types'; +import crypto from 'crypto'; + +function generateJoinCode(): string { + return crypto.randomBytes(3).toString('hex').toUpperCase(); +} + +export async function load({ locals }) { + if (!locals.user) { + throw error(500); + } + + // Check if user has a club membership + const membership = await db + .select({ + id: clubMembership.id, + role: clubMembership.role, + clubId: clubMembership.clubId, + clubName: club.name, + joinCode: club.joinCode + }) + .from(clubMembership) + .innerJoin(club, eq(clubMembership.clubId, club.id)) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + + if (membership.length === 0) { + return { linked: false }; + } + + const userMembership = membership[0]; + + if (userMembership.role != 'leader') { + userMembership.joinCode = null; + } + + // Get all members of this club + const members = await db + .select({ + id: user.id, + name: user.name, + profilePicture: user.profilePicture, + role: clubMembership.role + }) + .from(clubMembership) + .innerJoin(user, eq(clubMembership.userId, user.id)) + .where(eq(clubMembership.clubId, userMembership.clubId)); + + // Sort: leaders first, then members + members.sort((a, b) => { + if (a.role === 'leader' && b.role !== 'leader') return -1; + if (a.role !== 'leader' && b.role === 'leader') return 1; + return a.name.localeCompare(b.name); + }); + + // Calculate club hours from devlogs on shipped club projects + // Get project IDs that were shipped as this club + const clubShips = await db + .selectDistinct({ projectId: project.id }) + .from(project) + .innerJoin(ship, eq(ship.projectId, project.id)) + .where(and(eq(project.status, 'finalized'), eq(ship.clubId, userMembership.clubId))); + + let totalMinutes = 0; + if (clubShips.length > 0) { + const projectIds = clubShips.map((s) => s.projectId); + const [result] = await db + .select({ + total: sql`COALESCE(SUM(${devlog.timeSpent}), 0)` + }) + .from(devlog) + .where(and(inArray(devlog.projectId, projectIds), eq(devlog.deleted, false))); + totalMinutes = result?.total ?? 0; + } + + const totalHours = Math.floor(totalMinutes / 60); + + return { + linked: true, + clubName: userMembership.clubName, + joinCode: userMembership.joinCode, + role: userMembership.role, + members, + totalHours + }; +} + +export const actions = { + linkLeader: async ({ locals }) => { + if (!locals.user) { + throw error(500); + } + + // Check if already linked + const existing = await db + .select({ id: clubMembership.id }) + .from(clubMembership) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + + if (existing.length > 0) { + return fail(400, { alreadyLinked: true }); + } + + // Call external API to get leader's club + const leaderClub = await getLeaderClub(locals.user.slackId); + if (!leaderClub || !leaderClub.clubName) { + return fail(400, { notALeader: true }); + } + + // Upsert club + const [existingClub] = await db + .select({ id: club.id }) + .from(club) + .where(eq(club.name, leaderClub.clubName)) + .limit(1); + + let clubId: number; + const generatedJoinCode = generateJoinCode(); + if (existingClub) { + clubId = existingClub.id; + // Set join code if not already set + await db + .update(club) + .set({ joinCode: generatedJoinCode }) + .where(and(eq(club.id, clubId), sql`${club.joinCode} IS NULL`)); + } else { + const [newClub] = await db + .insert(club) + .values({ name: leaderClub.clubName, joinCode: generatedJoinCode }) + .returning({ id: club.id }); + clubId = newClub.id; + } + + // Create membership as leader + await db.insert(clubMembership).values({ + clubId, + userId: locals.user.id, + role: 'leader' + }); + + return { success: true }; + }, + + joinByCode: async ({ locals, request }) => { + if (!locals.user) { + throw error(500); + } + + const data = await request.formData(); + const code = data.get('code')?.toString()?.trim(); + + if (!code || code.length < 3) { + return fail(400, { invalidCode: true }); + } + + // Check if already linked + const existing = await db + .select({ id: clubMembership.id }) + .from(clubMembership) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + + if (existing.length > 0) { + return fail(400, { alreadyLinked: true }); + } + + // Look up club by join code in local database + const [existingClub] = await db + .select({ id: club.id, name: club.name }) + .from(club) + .where(eq(club.joinCode, code.toUpperCase())) + .limit(1); + + if (!existingClub) { + return fail(400, { invalidCode: true }); + } + + const clubId = existingClub.id; + + // Create membership as member + await db.insert(clubMembership).values({ + clubId, + userId: locals.user.id, + role: 'member' + }); + + return { success: true }; + }, + + regenerateJoinCode: async ({ locals }) => { + if (!locals.user) { + throw error(500); + } + + // Get user's membership (must be leader) + const [membership] = await db + .select({ clubId: clubMembership.clubId, role: clubMembership.role }) + .from(clubMembership) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + + if (!membership || membership.role !== 'leader') { + return fail(403, { notAllowed: true }); + } + + // Generate and set new join code + const newCode = generateJoinCode(); + await db.update(club).set({ joinCode: newCode }).where(eq(club.id, membership.clubId)); + + return { success: true }; + } +} satisfies Actions; diff --git a/src/routes/dashboard/clubs/+page.svelte b/src/routes/dashboard/clubs/+page.svelte new file mode 100644 index 0000000..563f4f5 --- /dev/null +++ b/src/routes/dashboard/clubs/+page.svelte @@ -0,0 +1,210 @@ + + + + +

Club

+ +{#if !data.linked} +
+
+

Link Your Club

+

+ Connect your Hack Club to track hours and collaborate with your team. +

+
+ +
+
+

Club Leaders

+

+ If you're a club leader, we'll automatically find your club. +

+
+
{ + isLinking = true; + return async ({ update }) => { + await update(); + isLinking = false; + }; + }} + > + +
+ {#if form?.notALeader} +

+ You don't appear to be a club leader. Ask your leader for a join code. +

+ {/if} +
+ +
+

Club Members

+

Enter the join code from your club leader.

+
{ + isJoining = true; + return async ({ update }) => { + await update(); + isJoining = false; + }; + }} + class="flex gap-2" + > + + +
+ {#if form?.invalidCode} +

+ Invalid join code. Please check with your club leader. +

+ {/if} +
+
+ + {#if form?.alreadyLinked} +

+ You're already linked to a club. Refresh to see your club. +

+ {/if} +
+{:else} +
+
+
+

{data.clubName}

+

+ {data.role === 'leader' ? 'Leader' : 'Member'} +

+
+
+

{data.totalHours}

+

club hours

+
+
+
+ + {#if data.role === 'leader'} +
+

Leader Tools

+ + {#if data.joinCode} +
+

Share this link to invite members:

+
+ + +
{ + isRegenerating = true; + return async ({ update }) => { + await update(); + isRegenerating = false; + }; + }} + > + +
+
+
+ {/if} + + {#if data.totalHours >= 50} +
+

🎉 Congratulations!

+

+ Your club has reached 50+ hours! You're eligible to request a 3D printer. +

+ + Request Printer → + +
+ {/if} +
+ {/if} + +
+

Members ({data.members?.length ?? 0})

+ +
+{/if} diff --git a/src/routes/dashboard/clubs/join/+page.server.ts b/src/routes/dashboard/clubs/join/+page.server.ts new file mode 100644 index 0000000..645b8a5 --- /dev/null +++ b/src/routes/dashboard/clubs/join/+page.server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; + +export function load({ url }) { + const code = url.searchParams.get('code'); + if (code) { + throw redirect(302, `/dashboard/clubs?code=${encodeURIComponent(code)}`); + } + throw redirect(302, '/dashboard/clubs'); +} diff --git a/src/routes/dashboard/projects/[id]/ship/+page.server.ts b/src/routes/dashboard/projects/[id]/ship/+page.server.ts index 3119c86..8fafefa 100644 --- a/src/routes/dashboard/projects/[id]/ship/+page.server.ts +++ b/src/routes/dashboard/projects/[id]/ship/+page.server.ts @@ -1,5 +1,5 @@ import { db } from '$lib/server/db/index.js'; -import { devlog, project } from '$lib/server/db/schema.js'; +import { devlog, project, clubMembership, club } from '$lib/server/db/schema.js'; import { error, fail, redirect } from '@sveltejs/kit'; import { eq, and, or, sql } from 'drizzle-orm'; import type { Actions } from './$types'; @@ -14,109 +14,122 @@ import { ship } from '$lib/server/db/schema.js'; import { sanitizeUrl } from '@braintree/sanitize-url'; export async function load({ params, locals }) { - const id: number = parseInt(params.id); - - if (!locals.user) { - throw error(500); - } - - const [queriedProject] = await db - .select({ - id: project.id, - name: project.name, - description: project.description, - - url: project.url, - editorFileType: project.editorFileType, - editorUrl: project.editorUrl, - uploadedFileUrl: project.uploadedFileUrl, - modelFile: project.modelFile, - - createdAt: project.createdAt, - status: project.status, - timeSpent: sql`COALESCE(SUM(${devlog.timeSpent}), 0)`, - devlogCount: sql`COALESCE(COUNT(${devlog.id}), 0)` - }) - .from(project) - .leftJoin(devlog, and(eq(project.id, devlog.projectId), eq(devlog.deleted, false))) - .where( - and( - eq(project.id, id), - eq(project.userId, locals.user.id), - eq(project.deleted, false), - or(eq(project.status, 'building'), eq(project.status, 'rejected')) - ) - ) - .groupBy( - project.id, - project.name, - project.description, - project.url, - project.createdAt, - project.status - ) - .limit(1); - - if (!queriedProject) { - throw error(404); - } - - return { - project: queriedProject - }; + const id: number = parseInt(params.id); + + if (!locals.user) { + throw error(500); + } + + const [queriedProject] = await db + .select({ + id: project.id, + name: project.name, + description: project.description, + + url: project.url, + editorFileType: project.editorFileType, + editorUrl: project.editorUrl, + uploadedFileUrl: project.uploadedFileUrl, + modelFile: project.modelFile, + + createdAt: project.createdAt, + status: project.status, + timeSpent: sql`COALESCE(SUM(${devlog.timeSpent}), 0)`, + devlogCount: sql`COALESCE(COUNT(${devlog.id}), 0)` + }) + .from(project) + .leftJoin(devlog, and(eq(project.id, devlog.projectId), eq(devlog.deleted, false))) + .where( + and( + eq(project.id, id), + eq(project.userId, locals.user.id), + eq(project.deleted, false), + or(eq(project.status, 'building'), eq(project.status, 'rejected')) + ) + ) + .groupBy( + project.id, + project.name, + project.description, + project.url, + project.createdAt, + project.status + ) + .limit(1); + + if (!queriedProject) { + throw error(404); + } + + // Check if user has a club membership + const membership = await db + .select({ + clubId: clubMembership.clubId, + clubName: club.name + }) + .from(clubMembership) + .innerJoin(club, eq(clubMembership.clubId, club.id)) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + + return { + project: queriedProject, + clubMembership: membership.length > 0 ? membership[0] : null + }; } export const actions = { - default: async ({ locals, params, request }) => { - if (!locals.user) { - throw error(500); - } - - const id: number = parseInt(params.id); - - const data = await request.formData(); - const printablesUrl = data.get('printables_url'); - const editorUrl = data.get('editor_url'); - const editorFile = data.get('editor_file') as File; - const modelFile = data.get('model_file') as File; - - const printablesUrlString = - printablesUrl && printablesUrl.toString() ? sanitizeUrl(printablesUrl.toString()) : null; - - const printablesUrlValid = - printablesUrlString && - printablesUrlString.trim().length < 8000 && - isValidUrl(printablesUrlString.trim()) && - printablesUrlString !== 'about:blank'; - - if (!printablesUrlValid) { - return fail(400, { - invalid_printables_url: true - }); - } - - const printablesUrlObj = new URL(printablesUrlString.trim()); - - const pathMatch = printablesUrlObj.pathname.match(/\/model\/(\d+)/); - const modelId = pathMatch ? pathMatch[1] : ''; - - const allowedLicenseIds = (env.PRINTABLES_ALLOWED_LICENSES_ID ?? '7,1,2,9,12,10,11') - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - if (allowedLicenseIds.length === 0) { - return error(500, { message: 'license validation not configured' }); - } - - try { - const graphqlResponse = await fetch('https://api.printables.com/graphql/', { - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ - operationName: 'PrintDetail', - query: `query PrintDetail($id: ID!) { + default: async ({ locals, params, request }) => { + if (!locals.user) { + throw error(500); + } + + const id: number = parseInt(params.id); + + const data = await request.formData(); + const printablesUrl = data.get('printables_url'); + const editorUrl = data.get('editor_url'); + const editorFile = data.get('editor_file') as File; + const modelFile = data.get('model_file') as File; + const submitAsClub = data.get('submit_as_club') === 'true'; + + const printablesUrlString = + printablesUrl && printablesUrl.toString() ? sanitizeUrl(printablesUrl.toString()) : null; + + const printablesUrlValid = + printablesUrlString && + printablesUrlString.trim().length < 8000 && + isValidUrl(printablesUrlString.trim()) && + printablesUrlString !== 'about:blank'; + + if (!printablesUrlValid) { + return fail(400, { + invalid_printables_url: true + }); + } + + const printablesUrlObj = new URL(printablesUrlString.trim()); + + const pathMatch = printablesUrlObj.pathname.match(/\/model\/(\d+)/); + const modelId = pathMatch ? pathMatch[1] : ''; + + const allowedLicenseIds = (env.PRINTABLES_ALLOWED_LICENSES_ID ?? '7,1,2,9,12,10,11') + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (allowedLicenseIds.length === 0) { + return error(500, { message: 'license validation not configured' }); + } + + try { + const graphqlResponse = await fetch('https://api.printables.com/graphql/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + operationName: 'PrintDetail', + query: `query PrintDetail($id: ID!) { print(id: $id) { id name @@ -126,186 +139,200 @@ export const actions = { } } }`, - variables: { id: modelId } - }) - }); - if (!graphqlResponse.ok) { - return fail(400, { - invalid_printables_url: true - }); - } - const graphqlData = await graphqlResponse.json(); - const license = graphqlData?.data?.print?.license; - - if (!license || !license.id) { - return fail(400, { - invalid_printables_url: true - }); - } - - const licenseMatch = allowedLicenseIds.some((allowed) => allowed === license.id.toString()); - - if (!licenseMatch) { - return fail(400, { - invalid_license: true - }); - } - } catch { - return fail(400, { - invalid_printables_url: true - }); - } - - // Editor URL - const editorUrlExists = editorUrl && editorUrl.toString(); - - const editorUrlString = editorUrlExists ? sanitizeUrl(editorUrl.toString()) : null; - - const editorUrlValid = - editorUrlString && editorUrlString.trim().length < 8000 && isValidUrl(editorUrlString.trim()); - - if (editorUrlExists && (!editorUrlValid || editorUrlString === 'about:blank')) { - return fail(400, { - invalid_editor_url: true - }); - } - - // Editor file - const editorFileExists = editorFile instanceof File && editorFile.size > 0; - const editorFileValid = editorFileExists && editorFile.size <= MAX_UPLOAD_SIZE; - - if (!editorUrlExists && !editorFileExists) { - return error(400, { message: "editor file or url doesn't exist" }); - } - - if (editorUrlExists && editorFileExists) { - return error(400, { message: 'editor file or url both exist' }); - } - - if (editorFileExists && !editorFileValid) { - return fail(400, { - invalid_editor_file: true - }); - } - - // Model file - const modelFileValid = - modelFile instanceof File && - modelFile.size > 0 && - modelFile.size <= MAX_UPLOAD_SIZE && - extname(modelFile.name).toLowerCase() == '.3mf' && - [ - 'model/3mf', - 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml', - 'application/vnd.ms-3mfdocument', - 'application/octet-stream', - 'text/plain' - ].includes(modelFile.type); - - if (!modelFileValid) { - return fail(400, { - invalid_model_file: true - }); - } - - const [queriedProject] = await db - .select({ - id: project.id, - name: project.name, - description: project.description, - url: project.url, - timeSpent: sql`COALESCE(SUM(${devlog.timeSpent}), 0)`, - devlogCount: sql`COALESCE(COUNT(${devlog.id}), 0)` - }) - .from(project) - .leftJoin(devlog, and(eq(project.id, devlog.projectId), eq(devlog.deleted, false))) - .where( - and( - eq(project.id, id), - eq(project.userId, locals.user.id), - eq(project.deleted, false), - or(eq(project.status, 'building'), eq(project.status, 'rejected')) - ) - ) - .groupBy(project.id, project.name, project.description, project.url) - .limit(1); - - if (!queriedProject) { - return error(404, { message: 'project not found' }); - } - - // Make sure it has at least 2h - if (queriedProject.timeSpent < 120) { - return error(400, { message: 'minimum 2h needed to ship' }); - } - - // Make sure it has at least 2 devlogs - if (queriedProject.devlogCount < 2) { - return error(400, { message: 'minimum 2 journal logs required to ship' }); - } - - if (queriedProject.description == '') { - return error(400, { message: 'project must have a description' }); - } - - // Editor file - const editorFilePath = `ships/editor-files/${crypto.randomUUID()}${extname(editorFile.name)}`; - - if (editorFileExists) { - const editorFileCommand = new PutObjectCommand({ - Bucket: env.S3_BUCKET_NAME, - Key: editorFilePath, - Body: Buffer.from(await editorFile.arrayBuffer()) - }); - await S3.send(editorFileCommand); - } - - // Models - const modelPath = `ships/models/${crypto.randomUUID()}${extname(modelFile.name).toLowerCase()}`; - - const modelCommand = new PutObjectCommand({ - Bucket: env.S3_BUCKET_NAME, - Key: modelPath, - Body: Buffer.from(await modelFile.arrayBuffer()) - }); - await S3.send(modelCommand); - - await db - .update(project) - .set({ - status: 'submitted', - url: printablesUrlString, - editorFileType: editorUrlExists ? 'url' : 'upload', - editorUrl: editorUrlExists ? editorUrlString : undefined, - uploadedFileUrl: editorFileExists ? editorFilePath : undefined, - - modelFile: modelPath - }) - .where( - and( - eq(project.id, queriedProject.id), - eq(project.userId, locals.user.id), - eq(project.deleted, false) - ) - ); - - await db.insert(ship).values({ - userId: locals.user.id, - projectId: queriedProject.id, - url: printablesUrlString, - - editorFileType: editorUrlExists ? 'url' : 'upload', - editorUrl: editorUrlExists ? editorUrlString : undefined, - uploadedFileUrl: editorFileExists ? editorFilePath : undefined, - - modelFile: modelPath - }); - - await sendSlackDM( - locals.user.slackId, - `Hii :hyper-dino-wave:\n Your project has been shipped and is now under review, we'll take a look and get back to you soon! :woooo:` - ); - - return redirect(303, '/dashboard/projects'); - } + variables: { id: modelId } + }) + }); + if (!graphqlResponse.ok) { + return fail(400, { + invalid_printables_url: true + }); + } + const graphqlData = await graphqlResponse.json(); + const license = graphqlData?.data?.print?.license; + + if (!license || !license.id) { + return fail(400, { + invalid_printables_url: true + }); + } + + const licenseMatch = allowedLicenseIds.some((allowed) => allowed === license.id.toString()); + + if (!licenseMatch) { + return fail(400, { + invalid_license: true + }); + } + } catch { + return fail(400, { + invalid_printables_url: true + }); + } + + // Editor URL + const editorUrlExists = editorUrl && editorUrl.toString(); + + const editorUrlString = editorUrlExists ? sanitizeUrl(editorUrl.toString()) : null; + + const editorUrlValid = + editorUrlString && editorUrlString.trim().length < 8000 && isValidUrl(editorUrlString.trim()); + + if (editorUrlExists && (!editorUrlValid || editorUrlString === 'about:blank')) { + return fail(400, { + invalid_editor_url: true + }); + } + + // Editor file + const editorFileExists = editorFile instanceof File && editorFile.size > 0; + const editorFileValid = editorFileExists && editorFile.size <= MAX_UPLOAD_SIZE; + + if (!editorUrlExists && !editorFileExists) { + return error(400, { message: "editor file or url doesn't exist" }); + } + + if (editorUrlExists && editorFileExists) { + return error(400, { message: 'editor file or url both exist' }); + } + + if (editorFileExists && !editorFileValid) { + return fail(400, { + invalid_editor_file: true + }); + } + + // Model file + const modelFileValid = + modelFile instanceof File && + modelFile.size > 0 && + modelFile.size <= MAX_UPLOAD_SIZE && + extname(modelFile.name).toLowerCase() == '.3mf' && + [ + 'model/3mf', + 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml', + 'application/vnd.ms-3mfdocument', + 'application/octet-stream', + 'text/plain' + ].includes(modelFile.type); + + if (!modelFileValid) { + return fail(400, { + invalid_model_file: true + }); + } + + const [queriedProject] = await db + .select({ + id: project.id, + name: project.name, + description: project.description, + url: project.url, + timeSpent: sql`COALESCE(SUM(${devlog.timeSpent}), 0)`, + devlogCount: sql`COALESCE(COUNT(${devlog.id}), 0)` + }) + .from(project) + .leftJoin(devlog, and(eq(project.id, devlog.projectId), eq(devlog.deleted, false))) + .where( + and( + eq(project.id, id), + eq(project.userId, locals.user.id), + eq(project.deleted, false), + or(eq(project.status, 'building'), eq(project.status, 'rejected')) + ) + ) + .groupBy(project.id, project.name, project.description, project.url) + .limit(1); + + if (!queriedProject) { + return error(404, { message: 'project not found' }); + } + + // Make sure it has at least 2h + if (queriedProject.timeSpent < 120) { + return error(400, { message: 'minimum 2h needed to ship' }); + } + + // Make sure it has at least 2 devlogs + if (queriedProject.devlogCount < 2) { + return error(400, { message: 'minimum 2 journal logs required to ship' }); + } + + if (queriedProject.description == '') { + return error(400, { message: 'project must have a description' }); + } + + // Editor file + const editorFilePath = `ships/editor-files/${crypto.randomUUID()}${extname(editorFile.name)}`; + + if (editorFileExists) { + const editorFileCommand = new PutObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: editorFilePath, + Body: Buffer.from(await editorFile.arrayBuffer()) + }); + await S3.send(editorFileCommand); + } + + // Models + const modelPath = `ships/models/${crypto.randomUUID()}${extname(modelFile.name).toLowerCase()}`; + + const modelCommand = new PutObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: modelPath, + Body: Buffer.from(await modelFile.arrayBuffer()) + }); + await S3.send(modelCommand); + + await db + .update(project) + .set({ + status: 'submitted', + url: printablesUrlString, + editorFileType: editorUrlExists ? 'url' : 'upload', + editorUrl: editorUrlExists ? editorUrlString : undefined, + uploadedFileUrl: editorFileExists ? editorFilePath : undefined, + + modelFile: modelPath + }) + .where( + and( + eq(project.id, queriedProject.id), + eq(project.userId, locals.user.id), + eq(project.deleted, false) + ) + ); + + // Get club ID if submitting as club + let clubIdForShip: number | null = null; + if (submitAsClub) { + const [membership] = await db + .select({ clubId: clubMembership.clubId }) + .from(clubMembership) + .where(eq(clubMembership.userId, locals.user.id)) + .limit(1); + if (membership) { + clubIdForShip = membership.clubId; + } + } + + await db.insert(ship).values({ + userId: locals.user.id, + projectId: queriedProject.id, + url: printablesUrlString, + + editorFileType: editorUrlExists ? 'url' : 'upload', + editorUrl: editorUrlExists ? editorUrlString : undefined, + uploadedFileUrl: editorFileExists ? editorFilePath : undefined, + + modelFile: modelPath, + clubId: clubIdForShip + }); + + await sendSlackDM( + locals.user.slackId, + `Hii :hyper-dino-wave:\n Your project has been shipped and is now under review, we'll take a look and get back to you soon! :woooo:` + ); + + return redirect(303, '/dashboard/projects'); + } } satisfies Actions; diff --git a/src/routes/dashboard/projects/[id]/ship/+page.svelte b/src/routes/dashboard/projects/[id]/ship/+page.svelte index fefb4a1..e078b16 100644 --- a/src/routes/dashboard/projects/[id]/ship/+page.svelte +++ b/src/routes/dashboard/projects/[id]/ship/+page.svelte @@ -16,6 +16,7 @@ let editorUrl = $state(data.project.editorUrl); let editorUploadFile = $state(null); let modelFile = $state(null); + let submitAsClub = $state(false); let hasEditorFile = $derived((editorUrl || editorUploadFile) && !(editorUrl && editorUploadFile)); @@ -164,6 +165,42 @@ {/if} + {#if data.clubMembership} +
+

Submit as

+
+ + + {#if submitAsClub} +

+ This project's hours will count toward your club's total. +

+ {/if} +
+
+ + {/if} +

Requirements

= 120} @@ -219,7 +256,7 @@ {#if data.project.timeSpent >= 120}
-

Estimate payout

+

{submitAsClub ? 'Filament usage' : 'Estimate payout'}

-

- You'll get {payoutEstimate.clay - ? Math.round(payoutEstimate.clay * 10) / 10 + ' clay' - : Math.round((payoutEstimate.bricks ?? 0) * 10) / 10 + ' bricks'} -

-

- This is just an estimate, not a guarantee - your journal time might be adjusted after - review. -

+ {#if submitAsClub} +

+ Club submissions do not receive personal currency rewards. +

+ {:else} +

+ You'll get {payoutEstimate.clay + ? Math.round(payoutEstimate.clay * 10) / 10 + ' clay' + : Math.round((payoutEstimate.bricks ?? 0) * 10) / 10 + ' bricks'} +

+

+ This is just an estimate, not a guarantee - your journal time might be adjusted after + review. +

+ {/if}
{:else}