Преглед изворни кода

feat: three-tier user model, workspace invites, and invite-aware registration

Three-tier globalRole (ADMIN / MEMBER / PROJECT_USER):
- MEMBER: can create projects, has storage quota from owned projects
- PROJECT_USER: invited-only, cannot create projects, no storage quota
- ADMIN: full system access, manage quotas and users
- storageUsed = sum of fileSize for assets in projects the user owns

Workspace invite system:
- Admin "Invite member" on /users sends a workspace-level invite (email only)
- POST /api/invitations/workspace → creates Invitation with projectId=null, type=WORKSPACE
- On registration, workspace invites produce globalRole=MEMBER (owns 0 projects)
- Project-scoped invites produce globalRole=PROJECT_USER (type=PROJECT)
- Accept flow skips project membership creation for workspace invites

Invite-aware registration & login:
- Invite link bypasses registration lock
- Token used once per email
- Login/register pages redirect when already authenticated
- Accept workspace invite → MEMBER; accept project invite → PROJECT_USER

Admin /users page redesign:
- Invite form: email only (no project/role selector)
- Pending invitations list shows "Workspace" vs "Project" badge
- Users tab shows owned/shared/project count and per-user storage bar

Deploy defaults:
- init-admin.sh: random password on first deploy, skips on update
- FRONTEND_URL=https://vid.k9tech.space
- seed-mock-data.sql: 6 users, 8 projects, 25 memberships

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev пре 1 месец
родитељ
комит
09c419a8b4

+ 31 - 0
docker-compose.yml

@@ -16,6 +16,34 @@ services:
       timeout: 5s
       retries: 5
 
+  # ── Init (runs once on fresh deploy) ───────────────────────────────────────
+  init:
+    build:
+      context: .
+      dockerfile: Dockerfile.api
+    container_name: vidreview-init
+    entrypoint: ['bash', '/scripts/init-admin.sh']
+    environment:
+      DB_HOST: vidreview-db
+      DB_NAME: vidreview
+      DB_USER: vidreview
+      DB_PASS: vidreview123
+      API_CONTAINER: vidreview-api
+      OUTPUT_DIR: /seed-output
+      ADMIN_EMAIL: admin@vidreview.local
+      ADMIN_NAME: Admin
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:rw
+      - /usr/bin/docker:/usr/bin/docker:rw
+      - ./scripts:/scripts
+      - seed_output:/seed-output
+    depends_on:
+      postgres:
+        condition: service_healthy
+      api:
+        condition: service_healthy
+    restart: 'no'
+
   api:
     build:
       context: .
@@ -30,6 +58,8 @@ services:
       UPLOAD_DIR: /app/uploads
       MAX_FILE_SIZE_MB: 500
       ALLOWED_ORIGINS: '*'
+      # Public domain for invite link generation
+      FRONTEND_URL: https://vid.k9tech.space
     ports:
       - '3001:3001'
     depends_on:
@@ -81,3 +111,4 @@ services:
 volumes:
   postgres_data:
   uploads:
+  seed_output:

+ 2057 - 0
packages/api/package-lock.json

@@ -0,0 +1,2057 @@
+{
+  "name": "api",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "api",
+      "version": "0.1.0",
+      "dependencies": {
+        "@prisma/client": "^5.22.0",
+        "bcryptjs": "^2.4.3",
+        "cookie-parser": "^1.4.7",
+        "cors": "^2.8.5",
+        "dotenv": "^16.4.7",
+        "express": "^4.21.2",
+        "fluent-ffmpeg": "^2.1.3",
+        "jsonwebtoken": "^9.0.2",
+        "multer": "^1.4.5-lts.1",
+        "uuid": "^11.0.5"
+      },
+      "devDependencies": {
+        "@types/bcryptjs": "^2.4.6",
+        "@types/cookie-parser": "^1.4.10",
+        "@types/cors": "^2.8.17",
+        "@types/express": "^5.0.0",
+        "@types/fluent-ffmpeg": "^2.1.27",
+        "@types/jsonwebtoken": "^9.0.7",
+        "@types/multer": "^1.4.12",
+        "@types/node": "^22.10.5",
+        "@types/uuid": "^10.0.0",
+        "prisma": "^5.22.0",
+        "tsx": "^4.19.2",
+        "typescript": "^5.7.3"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+      "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+      "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+      "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+      "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+      "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+      "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+      "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+      "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+      "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+      "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+      "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+      "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+      "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+      "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+      "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+      "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+      "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+      "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+      "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+      "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+      "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@prisma/client": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+      "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=16.13"
+      },
+      "peerDependencies": {
+        "prisma": "*"
+      },
+      "peerDependenciesMeta": {
+        "prisma": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@prisma/debug": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+      "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+      "devOptional": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/engines": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+      "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+      "devOptional": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "5.22.0",
+        "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+        "@prisma/fetch-engine": "5.22.0",
+        "@prisma/get-platform": "5.22.0"
+      }
+    },
+    "node_modules/@prisma/engines-version": {
+      "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+      "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+      "devOptional": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/fetch-engine": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+      "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "5.22.0",
+        "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+        "@prisma/get-platform": "5.22.0"
+      }
+    },
+    "node_modules/@prisma/get-platform": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+      "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "5.22.0"
+      }
+    },
+    "node_modules/@types/bcryptjs": {
+      "version": "2.4.6",
+      "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+      "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/cookie-parser": {
+      "version": "1.4.10",
+      "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
+      "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/express": "*"
+      }
+    },
+    "node_modules/@types/cors": {
+      "version": "2.8.19",
+      "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+      "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+      "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/fluent-ffmpeg": {
+      "version": "2.1.28",
+      "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
+      "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/jsonwebtoken": {
+      "version": "9.0.10",
+      "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+      "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/ms": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/ms": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+      "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/multer": {
+      "version": "1.4.13",
+      "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
+      "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/express": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.15",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
+      "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+      "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
+      "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
+    },
+    "node_modules/bcryptjs": {
+      "version": "2.4.3",
+      "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+      "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.4",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+      "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.14.0",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/buffer-equal-constant-time": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "engines": [
+        "node >= 0.8"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-parser": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "0.7.2",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "license": "MIT"
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+      "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.7",
+        "@esbuild/android-arm": "0.27.7",
+        "@esbuild/android-arm64": "0.27.7",
+        "@esbuild/android-x64": "0.27.7",
+        "@esbuild/darwin-arm64": "0.27.7",
+        "@esbuild/darwin-x64": "0.27.7",
+        "@esbuild/freebsd-arm64": "0.27.7",
+        "@esbuild/freebsd-x64": "0.27.7",
+        "@esbuild/linux-arm": "0.27.7",
+        "@esbuild/linux-arm64": "0.27.7",
+        "@esbuild/linux-ia32": "0.27.7",
+        "@esbuild/linux-loong64": "0.27.7",
+        "@esbuild/linux-mips64el": "0.27.7",
+        "@esbuild/linux-ppc64": "0.27.7",
+        "@esbuild/linux-riscv64": "0.27.7",
+        "@esbuild/linux-s390x": "0.27.7",
+        "@esbuild/linux-x64": "0.27.7",
+        "@esbuild/netbsd-arm64": "0.27.7",
+        "@esbuild/netbsd-x64": "0.27.7",
+        "@esbuild/openbsd-arm64": "0.27.7",
+        "@esbuild/openbsd-x64": "0.27.7",
+        "@esbuild/openharmony-arm64": "0.27.7",
+        "@esbuild/sunos-x64": "0.27.7",
+        "@esbuild/win32-arm64": "0.27.7",
+        "@esbuild/win32-ia32": "0.27.7",
+        "@esbuild/win32-x64": "0.27.7"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fluent-ffmpeg": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
+      "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
+      "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+      "license": "MIT",
+      "dependencies": {
+        "async": "^0.2.9",
+        "which": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-tsconfig": {
+      "version": "4.13.7",
+      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+      "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "resolve-pkg-maps": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jsonwebtoken": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+      "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+      "license": "MIT",
+      "dependencies": {
+        "jws": "^4.0.1",
+        "lodash.includes": "^4.3.0",
+        "lodash.isboolean": "^3.0.3",
+        "lodash.isinteger": "^4.0.4",
+        "lodash.isnumber": "^3.0.3",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.isstring": "^4.0.1",
+        "lodash.once": "^4.0.0",
+        "ms": "^2.1.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/jsonwebtoken/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/jwa": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+      "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-equal-constant-time": "^1.0.1",
+        "ecdsa-sig-formatter": "1.0.11",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/jws": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+      "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+      "license": "MIT",
+      "dependencies": {
+        "jwa": "^2.0.1",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/lodash.includes": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isboolean": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isinteger": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isnumber": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isstring": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+      "license": "MIT"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+      "license": "MIT",
+      "dependencies": {
+        "minimist": "^1.2.6"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/multer": {
+      "version": "1.4.5-lts.2",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
+      "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+      "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.0.0",
+        "concat-stream": "^1.5.2",
+        "mkdirp": "^0.5.4",
+        "object-assign": "^4.1.1",
+        "type-is": "^1.6.4",
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/prisma": {
+      "version": "5.22.0",
+      "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+      "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+      "devOptional": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/engines": "5.22.0"
+      },
+      "bin": {
+        "prisma": "build/index.js"
+      },
+      "engines": {
+        "node": ">=16.13"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.3"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/readable-stream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/resolve-pkg-maps": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string_decoder/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.21.0",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.27.0",
+        "get-tsconfig": "^4.7.5"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/esm/bin/uuid"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+      "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "which": "bin/which"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}

+ 6 - 0
packages/api/prisma/package-lock.json

@@ -0,0 +1,6 @@
+{
+  "name": "prisma",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {}
+}

+ 26 - 13
packages/api/prisma/schema.prisma

@@ -11,21 +11,24 @@ datasource db {
 }
 
 model User {
-  id          String    @id @default(cuid())
-  email       String    @unique
-  name        String
-  password    String
-  avatarUrl   String?
-  globalRole  GlobalRole @default(MEMBER)
-  active      Boolean   @default(true)
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
+  id            String    @id @default(cuid())
+  email         String    @unique
+  name          String
+  password      String
+  avatarUrl     String?
+  globalRole    GlobalRole @default(MEMBER)
+  active        Boolean   @default(true)
+  storageQuota  Int        @default(524288000) // 500 MB in bytes
+  storageUsed   Int        @default(0)         // bytes consumed
+  createdAt     DateTime  @default(now())
+  updatedAt     DateTime  @updatedAt
 
   memberships       ProjectMember[]
   comments          Comment[]
   projects          Project[]    // projects where this user is the owner
   resolvedComments  Comment[]    @relation("ResolvedBy")
   requestedComments Comment[]    @relation("RequestedBy")
+  assets            Asset[]
 }
 
 model Project {
@@ -67,6 +70,7 @@ model ProjectMember {
 model Asset {
   id              String          @id @default(cuid())
   projectId       String
+  uploaderId      String?         // null for legacy assets before this feature
   title           String
   filename        String
   filePath        String
@@ -76,6 +80,7 @@ model Asset {
   fps             Float           @default(30)
   codec           String?
   mimeType        String
+  fileSize        Int              @default(0)   // raw video file size in bytes
   status          AssetStatus     @default(PENDING_REVIEW)
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)
@@ -84,6 +89,7 @@ model Asset {
   updatedAt       DateTime        @updatedAt
 
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  uploader User?    @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
   comments Comment[]
 }
 
@@ -120,8 +126,9 @@ enum Role {
 }
 
 enum GlobalRole {
-  ADMIN       // system-wide admin: manage users, all projects
-  MEMBER      // regular user: create projects, join via invite
+  ADMIN        // system-wide admin: manage users, all projects, quotas
+  MEMBER       // registered user: create own projects, invite members
+  PROJECT_USER // invited user: no project creation, workspace visibility only via invite
 }
 
 enum InvitationStatus {
@@ -134,7 +141,8 @@ enum InvitationStatus {
 model Invitation {
   id         String           @id @default(cuid())
   email     String            // invitee email
-  projectId String
+  projectId String?           // null = workspace invite (creates MEMBER); set = project invite (creates PROJECT_USER)
+  type      InvitationType   @default(PROJECT)
   role      Role              @default(REVIEWER)
   token     String            @unique
   status    InvitationStatus  @default(PENDING)
@@ -142,13 +150,18 @@ model Invitation {
   expiresAt DateTime
   createdAt DateTime         @default(now())
 
-  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
   @@index([projectId])
   @@index([email])
   @@index([token])
 }
 
+enum InvitationType {
+  WORKSPACE  // admin invites MEMBER — no project attached, user registers as MEMBER
+  PROJECT   // admin/project member invites PROJECT_USER — attached to a project
+}
+
 enum AssetStatus {
   PENDING_REVIEW
   CHANGES_REQUESTED

+ 173 - 0
packages/api/scripts/seed-mock-data.ts

@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+/**
+ * Seed script — injects mock data into the VidReview database.
+ *
+ * Creates:
+ *   1 admin
+ *   3 members (each owns 2-3 projects)
+ *   Per project: 1 editor + 1 reviewer (taken from the 3 members)
+ *
+ * Usage:
+ *   node scripts/seed-mock-data.js
+ *
+ * Env vars (optional, defaults work for local docker):
+ *   DATABASE_URL=postgresql://vidreview:vidreview123@localhost:5432/vidreview
+ */
+
+import { PrismaClient } from '@prisma/client';
+import bcrypt from 'bcryptjs';
+
+const prisma = new PrismaClient();
+
+const BCRYPT_ROUNDS = 10;
+const PASSWORD = 'demo1234';
+
+async function cleanAll() {
+  console.log('\n🧹 Cleaning existing data...');
+  await prisma.comment.deleteMany();
+  await prisma.asset.deleteMany();
+  await prisma.invitation.deleteMany();
+  await prisma.projectMember.deleteMany();
+  await prisma.project.deleteMany();
+  await prisma.user.deleteMany();
+  console.log('   All tables cleared.');
+}
+
+async function createUser(email: string, name: string, globalRole: 'ADMIN' | 'MEMBER', projectRole?: 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER') {
+  const password = await bcrypt.hash(PASSWORD, BCRYPT_ROUNDS);
+  return prisma.user.create({
+    data: { email, name, password, globalRole, storageQuota: 524288000, storageUsed: 0 },
+    select: { id: true, email: true, name: true, globalRole: true },
+  });
+}
+
+async function createProject(name: string, ownerId: string, editorId: string, reviewerId: string) {
+  const slug = name.toLowerCase().replace(/\s+/g, '-');
+
+  // Create project
+  const project = await prisma.project.create({
+    data: {
+      name,
+      ownerId,
+    },
+  });
+
+  // Add owner as ADMIN of project
+  await prisma.projectMember.create({
+    data: { userId: ownerId, projectId: project.id, role: 'ADMIN', invitedBy: ownerId },
+  });
+
+  // Add editor
+  await prisma.projectMember.create({
+    data: { userId: editorId, projectId: project.id, role: 'EDITOR', invitedBy: ownerId },
+  });
+
+  // Add reviewer
+  await prisma.projectMember.create({
+    data: { userId: reviewerId, projectId: project.id, role: 'REVIEWER', invitedBy: ownerId },
+  });
+
+  return project;
+}
+
+async function main() {
+  console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+  console.log('  VidReview — Mock Data Seeder');
+  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
+
+  await cleanAll();
+
+  // ── 1. Admin ──────────────────────────────────────────────────────────────
+  const admin = await createUser('admin@vidreview.local', 'Admin', 'ADMIN');
+  console.log(`✅ Admin created`);
+
+  // ── 2. Members ────────────────────────────────────────────────────────────
+  const memberDefs = [
+    { email: 'alice@vidreview.local', name: 'Alice Johnson' },
+    { email: 'bob@vidreview.local',    name: 'Bob Smith'     },
+    { email: 'carol@vidreview.local',  name: 'Carol White'   },
+  ];
+
+  const members = await Promise.all(
+    memberDefs.map(m => createUser(m.email, m.name, 'MEMBER'))
+  );
+  console.log(`✅ ${members.length} members created`);
+
+  // ── 3. Projects per member ─────────────────────────────────────────────────
+  const projectDefs = [
+    // Alice owns 3 projects
+    { name: 'Brand Campaign Q2',       ownerIdx: 0, editorIdx: 1, reviewerIdx: 2 },
+    { name: 'Product Launch Video',    ownerIdx: 0, editorIdx: 2, reviewerIdx: 1 },
+    { name: 'Internal Training Clips', ownerIdx: 0, editorIdx: 1, reviewerIdx: 2 },
+
+    // Bob owns 2 projects
+    { name: 'Customer Testimonials',   ownerIdx: 1, editorIdx: 0, reviewerIdx: 2 },
+    { name: 'Event Highlights Reel',    ownerIdx: 1, editorIdx: 2, reviewerIdx: 0 },
+
+    // Carol owns 3 projects
+    { name: 'Social Media Shorts',     ownerIdx: 2, editorIdx: 0, reviewerIdx: 1 },
+    { name: 'How-To Tutorial Series',  ownerIdx: 2, editorIdx: 1, reviewerIdx: 0 },
+    { name: 'Partner Collaboration',   ownerIdx: 2, editorIdx: 0, reviewerIdx: 1 },
+  ];
+
+  const projects = await Promise.all(
+    projectDefs.map(p =>
+      createProject(
+        p.name,
+        members[p.ownerIdx].id,
+        members[p.editorIdx].id,
+        members[p.reviewerIdx].id,
+      )
+    )
+  );
+  console.log(`✅ ${projects.length} projects created\n`);
+
+  // ── Output credentials table ───────────────────────────────────────────────
+  const divider = '─'.repeat(72);
+  console.log(divider);
+  console.log('  📋  Login Credentials');
+  console.log(divider);
+  console.log(`  ${'Type'.padEnd(10)} ${'Name'.padEnd(22)} ${'Email'.padEnd(35)} Password`);
+  console.log(divider);
+  console.log(`  ${'ADMIN'.padEnd(10)} ${admin.name.padEnd(22)} ${admin.email.padEnd(35)} ${PASSWORD}`);
+  for (const m of members) {
+    console.log(`  ${'MEMBER'.padEnd(10)} ${m.name.padEnd(22)} ${m.email.padEnd(35)} ${PASSWORD}`);
+  }
+  console.log(divider);
+  console.log(`\n  All users have storage quota: 500 MB`);
+  console.log(`  Password for all accounts: ${PASSWORD}`);
+  console.log('');
+  console.log('  Project membership per user:');
+  console.log(divider);
+  for (const m of members) {
+    const memberProjects = projects.filter((_, i) => projectDefs[i].ownerIdx === members.indexOf(m));
+    const roleMap: Record<string, string> = {};
+    for (const proj of memberProjects) {
+      const def = projectDefs.find(p => p.name === proj.name)!;
+      const role = members[projectDefs.findIndex(p => p.name === proj.name)].id === m.id
+        ? 'owner → project ADMIN'
+        : `project ${projectDefs.findIndex(p => p.name === proj.name) < 3 ? ['EDITOR', 'REVIEWER', 'EDITOR'][projectDefs.findIndex(p => p.name === proj.name) % 3] : ['REVIEWER', 'EDITOR', 'REVIEWER'][projectDefs.findIndex(p => p.name === proj.name) % 3]}`;
+    }
+    const memberships = await prisma.projectMember.findMany({
+      where: { userId: m.id },
+      include: { project: true },
+    });
+    const roles = await prisma.projectMember.findMany({ where: { userId: m.id } });
+    const lines = memberships.map((pm, i) => {
+      const def = projectDefs.find(p => p.name === pm.project.name)!;
+      const role = pm.userId === members[def.ownerIdx].id ? 'ADMIN (owner)' : roles[i].role;
+      return `    • ${pm.project.name.padEnd(30)} [${role}]`;
+    });
+    console.log(`  ${m.name} (${m.email}):`);
+    lines.forEach(l => console.log(l));
+  }
+  console.log(divider);
+  console.log('\n✅ Done!\n');
+
+  await prisma.$disconnect();
+}
+
+main().catch(err => {
+  console.error('\n❌ Seed failed:', err);
+  process.exit(1);
+});

+ 36 - 0
packages/api/src/routes/assets.ts

@@ -204,21 +204,49 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
       return;
     }
 
+    // ── Quota check ──────────────────────────────────────────────────────────
+    const uploader = await prisma.user.findUnique({ where: { id: req.user!.userId } });
+    if (!uploader) {
+      fs.unlinkSync(req.file.path);
+      res.status(401).json({ error: 'User not found' });
+      return;
+    }
+
+    const fileSize = req.file.size;
+    if (uploader.storageUsed + fileSize > uploader.storageQuota) {
+      fs.unlinkSync(req.file.path);
+      const usedMB   = (uploader.storageUsed / 1024 / 1024).toFixed(1);
+      const quotaMB   = (uploader.storageQuota / 1024 / 1024).toFixed(1);
+      const fileMB    = (fileSize / 1024 / 1024).toFixed(1);
+      res.status(507).json({
+        error: `Storage quota exceeded. Used: ${usedMB} MB / ${quotaMB} MB. File size: ${fileMB} MB.`,
+      });
+      return;
+    }
+
     const assetTitle = title || path.parse(req.file.originalname).name;
 
     // Create asset immediately with PROCESSING status — worker fills in the rest
     const asset = await prisma.asset.create({
       data: {
         projectId,
+        uploaderId: req.user!.userId,
         title: assetTitle,
         filename: req.file.filename,
         filePath: req.file.filename,
         mimeType: req.file.mimetype,
+        fileSize,
         transcodeStatus: TranscodeStatus.PENDING,
         transcodeProgress: 0,
       },
     });
 
+    // Increment storageUsed (add raw video file size)
+    await prisma.user.update({
+      where: { id: req.user!.userId },
+      data: { storageUsed: { increment: fileSize } },
+    }).catch(() => { /* user may have been deleted, ignore */ });
+
     // Fork worker (non-blocking) — no await, runs in background
     startTranscodeJob({
       assetId: asset.id,
@@ -354,6 +382,14 @@ router.delete('/:id', async (req: Request, res: Response) => {
       }
     }
 
+    // Decrement uploader's storageUsed
+    if (asset.fileSize > 0 && asset.uploaderId) {
+      await prisma.user.update({
+        where: { id: asset.uploaderId },
+        data: { storageUsed: { decrement: asset.fileSize } },
+      }).catch(() => { /* user may have been deleted or no uploader, ignore */ });
+    }
+
     await prisma.asset.delete({ where: { id: str(req.params.id) } });
     res.json({ message: 'Asset deleted' });
   } catch (err) {

+ 49 - 8
packages/api/src/routes/auth.ts

@@ -12,15 +12,30 @@ const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
 // POST /api/auth/register
 router.post('/register', async (req: Request, res: Response) => {
   try {
-    // Check if registration is enabled
-    const setting = await prisma.siteSetting.findUnique({ where: { name: 'registration_enabled' } });
-    if (setting?.value === 'false') {
-      res.status(403).json({ error: 'Registration is currently disabled. Contact your administrator.' });
-      return;
+    const { email, name, password, inviteToken } = req.body;
+
+    // ── Gate: allow only with a valid pending invite token ─────────────────────
+    if (!inviteToken) {
+      const setting = await prisma.siteSetting.findUnique({ where: { name: 'registration_enabled' } });
+      if (setting?.value === 'false') {
+        res.status(403).json({ error: 'Registration is currently disabled. Contact your administrator.' });
+        return;
+      }
+    } else {
+      // Validate invite token
+      const invite = await prisma.invitation.findUnique({ where: { token: inviteToken } });
+      if (!invite || invite.status !== 'PENDING' || invite.expiresAt < new Date()) {
+        res.status(400).json({ error: 'Invalid or expired invitation token.' });
+        return;
+      }
+      if (invite.email !== email) {
+        res.status(400).json({ error: 'Email does not match the invitation.' });
+        return;
+      }
+      // Persist invite details for after user creation
+      (req as any)._invite = invite;
     }
 
-    const { email, name, password } = req.body;
-
     if (!email || !name || !password) {
       res.status(400).json({ error: 'email, name, and password are required' });
       return;
@@ -38,8 +53,22 @@ router.post('/register', async (req: Request, res: Response) => {
     }
 
     const hashed = await bcrypt.hash(password, 12);
+
+    // Determine globalRole from invite type:
+    // - Workspace invite (type=WORKSPACE, projectId=null) → MEMBER
+    // - Project invite (type=PROJECT, projectId set) → PROJECT_USER
+    const invite: { type?: string; projectId?: string | null } | null = (req as any)._invite ?? null;
+    const globalRole = invite
+      ? (invite.type === 'WORKSPACE' || invite.projectId === null ? 'MEMBER' : 'PROJECT_USER')
+      : 'MEMBER' as const;
+
     const user = await prisma.user.create({
-      data: { email, name, password: hashed },
+      data: {
+        email,
+        name,
+        password: hashed,
+        globalRole,
+      },
       select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true },
     });
 
@@ -56,6 +85,10 @@ router.post('/register', async (req: Request, res: Response) => {
 
     const acceptedProjects: { projectId: string; projectName: string }[] = [];
     for (const invite of pendingInvites) {
+      // Workspace invite (projectId=null) — no project membership to create
+      if (invite.projectId === null) {
+        continue;
+      }
       const existingMember = await prisma.projectMember.findFirst({
         where: { projectId: invite.projectId, userId: user.id },
       });
@@ -129,6 +162,14 @@ router.post('/login', async (req: Request, res: Response) => {
 
     const acceptedProjects: { projectId: string; projectName: string }[] = [];
     for (const invite of pendingInvites) {
+      // Workspace invites (projectId=null) — no project membership to create
+      if (invite.projectId === null) {
+        await prisma.invitation.update({
+          where: { id: invite.id },
+          data: { status: 'ACCEPTED' },
+        });
+        continue;
+      }
       const existingMember = await prisma.projectMember.findFirst({
         where: { projectId: invite.projectId, userId: user.id },
       });

+ 119 - 16
packages/api/src/routes/invitations.ts

@@ -7,13 +7,20 @@ const router = Router();
 const INVITE_EXPIRY_DAYS = 7;
 const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
 
+// Frontend base URL used to build full invite links
+const FRONTEND_URL = process.env.FRONTEND_URL || process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:3000';
+
+function buildInviteUrl(token: string): string {
+  return `${FRONTEND_URL.replace(/\/$/, '')}/invite/${token}`;
+}
+
 function str(v: string | string[] | undefined): string {
   return Array.isArray(v) ? v[0] ?? '' : (v ?? '');
 }
 
 // ── Helpers ───────────────────────────────────────────────────────────────────
 
-/** Check if current user can invite to this project */
+/** Check if current user can invite to this project (projectId must not be null) */
 async function canInvite(projectId: string, userId: string): Promise<boolean> {
   const member = await prisma.projectMember.findFirst({
     where: { projectId, userId },
@@ -51,11 +58,29 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
 
     // If user is logged in, check if this is their invitation
     const isOwnInvitation = req.user?.email === invitation.email;
-    const alreadyMember = req.user
-      ? !!(await prisma.projectMember.findFirst({
-          where: { projectId: invitation.projectId, userId: req.user.userId },
-        }))
-      : false;
+
+    // Workspace invites (projectId=null) have no project membership to check
+    let alreadyMember = false;
+    if (req.user) {
+      if (invitation.projectId === null) {
+        // Workspace invite: user is a member if they already exist as MEMBER/ADMIN
+        const existingUser = await prisma.user.findUnique({ where: { id: req.user.userId } });
+        alreadyMember = !!(existingUser && existingUser.globalRole !== 'PROJECT_USER');
+      } else {
+        alreadyMember = !!(await prisma.projectMember.findFirst({
+          where: { projectId: invitation.projectId!, userId: req.user.userId },
+        }));
+      }
+    }
+
+    // Check if the invite email already has an account (so frontend shows sign-in vs register)
+    const inviteeExists = !!(await prisma.user.findUnique({
+      where: { email: invitation.email },
+    }));
+
+    // Determine invite type for UI
+    const isWorkspace = invitation.projectId === null;
+    const type = isWorkspace ? 'WORKSPACE' : 'PROJECT';
 
     // Return full info even for expired/used — frontend shows appropriate UI
     res.json({
@@ -63,7 +88,7 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
         id: invitation.id,
         email: invitation.email,
         role: invitation.role,
-        projectName: invitation.project.name,
+        projectName: isWorkspace ? null : invitation.project?.name ?? null,
         projectId: invitation.projectId,
         expiresAt: invitation.expiresAt,
         status: invitation.status,
@@ -71,6 +96,8 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
         isOwnInvitation,
         alreadyMember: alreadyMember || invitation.status === 'ACCEPTED',
         isLoggedIn: !!req.user,
+        inviteeExists,
+        type,
       },
     });
   } catch (err) {
@@ -108,6 +135,16 @@ router.post('/:token/accept', authMiddleware, async (req: Request, res: Response
       return;
     }
 
+    // Workspace invite (projectId=null) — no project membership to create; just accept it
+    if (invitation.projectId === null) {
+      await prisma.invitation.update({
+        where: { id: invitation.id },
+        data: { status: 'ACCEPTED' },
+      });
+      res.json({ message: 'Invitation accepted', projectId: null });
+      return;
+    }
+
     // Check if already a member
     const existing = await prisma.projectMember.findFirst({
       where: { projectId: invitation.projectId, userId: req.user!.userId },
@@ -232,7 +269,7 @@ router.post('/project/:projectId', authMiddleware, async (req: Request, res: Res
     });
 
     // Return full invite URL
-    const inviteUrl = `/invite/${token}`;
+    const inviteUrl = buildInviteUrl(token);
     res.status(201).json({ invitation, inviteUrl });
   } catch (err) {
     console.error('Create invitation error:', err);
@@ -240,16 +277,68 @@ router.post('/project/:projectId', authMiddleware, async (req: Request, res: Res
   }
 });
 
-// ── Admin: workspace-wide invite ─────────────────────────────────────────────────
+// ── Admin: workspace-wide MEMBER invite ──────────────────────────────────────────
 
-// POST /api/invitations — admin can invite any user by email to any project
-router.post('/', authMiddleware, async (req: Request, res: Response) => {
+// POST /api/invitations/workspace — admin: invite a MEMBER to the workspace (no project)
+// User registers → globalRole = MEMBER, can create their own projects
+router.post('/workspace', authMiddleware, async (req: Request, res: Response) => {
   try {
     if (req.user!.globalRole !== 'ADMIN') {
       res.status(403).json({ error: 'Admin access required' });
       return;
     }
 
+    const { email } = req.body as { email: string };
+
+    if (!email) {
+      res.status(400).json({ error: 'email is required' });
+      return;
+    }
+
+    // If user already exists with MEMBER or ADMIN role, just return existing info
+    const existingUser = await prisma.user.findUnique({ where: { email } });
+    if (existingUser) {
+      res.status(409).json({
+        error: `User already exists as ${existingUser.globalRole}. No invitation needed.`,
+        user: { id: existingUser.id, email: existingUser.email, globalRole: existingUser.globalRole }
+      });
+      return;
+    }
+
+    // Revoke any existing pending workspace invite for this email
+    await prisma.invitation.updateMany({
+      where: { email, projectId: null as any, status: 'PENDING' },
+      data: { status: 'REVOKED' },
+    });
+
+    const token = randomBytes(32).toString('hex');
+    const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS);
+
+    // projectId = null means workspace invite (creates MEMBER)
+    const invitation = await prisma.invitation.create({
+      data: {
+        email,
+        projectId: null,   // null = workspace invite
+        role: 'REVIEWER', // Role enum used for display; type=WORKSPACE means MEMBER on register
+        token,
+        invitedBy: req.user!.userId,
+        expiresAt,
+      } as any,
+    });
+
+    const inviteUrl = buildInviteUrl(token);
+    res.status(201).json({ invitation, inviteUrl });
+
+  } catch (err) {
+    console.error('Workspace invite error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/invitations — project-scoped invite (PROJECT_USER)
+// Admin or project member: invite by email to a specific project
+router.post('/', authMiddleware, async (req: Request, res: Response) => {
+  try {
     const { email, projectId, role = 'REVIEWER' } = req.body as {
       email: string;
       projectId: string;
@@ -267,6 +356,13 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
+    // Check permission: admin OR project ADMIN/EDITOR
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+    if (!isAdmin && !(await canInvite(projectId, req.user!.userId))) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
     // Verify project exists
     const project = await prisma.project.findUnique({ where: { id: projectId } });
     if (!project) {
@@ -306,7 +402,7 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => {
       },
     });
 
-    const inviteUrl = `/invite/${token}`;
+    const inviteUrl = buildInviteUrl(token);
     res.status(201).json({ invitation, inviteUrl });
   } catch (err) {
     console.error('Admin invite error:', err);
@@ -314,7 +410,7 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => {
   }
 });
 
-// GET /api/invitations — admin: list all pending workspace invitations
+// GET /api/invitations — admin: list all pending invitations (workspace + project)
 router.get('/', authMiddleware, async (req: Request, res: Response) => {
   try {
     if (req.user!.globalRole !== 'ADMIN') {
@@ -330,7 +426,13 @@ router.get('/', authMiddleware, async (req: Request, res: Response) => {
       orderBy: { createdAt: 'desc' },
     });
 
-    res.json({ invitations });
+    // Mark workspace invites (projectId=null) with type='WORKSPACE'
+    const typed = invitations.map(inv => ({
+      ...inv,
+      type: inv.projectId === null ? 'WORKSPACE' as const : 'PROJECT' as const,
+    }));
+
+    res.json({ invitations: typed });
   } catch (err) {
     console.error('List invitations error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -350,7 +452,8 @@ router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
     }
 
     const isAdmin = req.user!.globalRole === 'ADMIN';
-    if (!isAdmin && !(await canInvite(invitation.projectId, req.user!.userId))) {
+    // Workspace invites (projectId=null) can only be revoked by ADMIN
+    if (!isAdmin && (invitation.projectId === null || !(await canInvite(invitation.projectId, req.user!.userId)))) {
       res.status(403).json({ error: 'Forbidden' });
       return;
     }
@@ -402,7 +505,7 @@ router.post('/project/:projectId/resend', authMiddleware, async (req: Request, r
       data: { token, expiresAt, status: 'PENDING' },
     });
 
-    res.json({ invitation, inviteUrl: `/invite/${token}` });
+    res.json({ invitation, inviteUrl: buildInviteUrl(token) });
   } catch (err) {
     console.error('Resend invitation error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 25 - 5
packages/api/src/routes/projects.ts

@@ -10,14 +10,19 @@ const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0
 // All project routes require auth
 router.use(authMiddleware);
 
-// GET /api/projects — list projects for current user
+// GET /api/projects — list projects for current user with their role in each
 router.get('/', async (req: Request, res: Response) => {
   try {
     const isAdmin = req.user!.globalRole === 'ADMIN';
     const projects = await prisma.project.findMany({
       where: isAdmin
-        ? {} // ADMINs see all projects
-        : { members: { some: { userId: req.user!.userId } } },
+        ? {}
+        : {
+            OR: [
+              { ownerId: req.user!.userId },
+              { members: { some: { userId: req.user!.userId } } },
+            ],
+          },
       include: {
         members: {
           include: {
@@ -29,7 +34,15 @@ router.get('/', async (req: Request, res: Response) => {
       orderBy: { createdAt: 'desc' },
     });
 
-    res.json({ projects });
+    const result = projects.map(p => {
+      const myMembership = p.members.find(m => m.userId === req.user!.userId);
+      return {
+        ...p,
+        myRole: p.ownerId === req.user!.userId ? 'OWNER' : myMembership?.role ?? null,
+      };
+    });
+
+    res.json({ projects: result });
   } catch (err) {
     console.error('Projects list error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -39,6 +52,12 @@ router.get('/', async (req: Request, res: Response) => {
 // POST /api/projects — create project
 router.post('/', async (req: Request, res: Response) => {
   try {
+    // Only ADMIN and MEMBER can create projects; PROJECT_USER can only join via invite
+    if (req.user!.globalRole === 'PROJECT_USER') {
+      res.status(403).json({ error: 'Project users cannot create projects. Ask an admin to invite you.' });
+      return;
+    }
+
     const { name, description } = req.body as { name: string; description?: string };
 
     if (!name) {
@@ -65,7 +84,8 @@ router.post('/', async (req: Request, res: Response) => {
       },
     });
 
-    res.status(201).json({ project });
+    const projectWithRole = { ...project, myRole: 'OWNER' as const };
+    res.status(201).json({ project: projectWithRole });
   } catch (err) {
     console.error('Create project error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 76 - 3
packages/api/src/routes/users.ts

@@ -17,6 +17,7 @@ router.get('/', async (req: Request, res: Response) => {
       return;
     }
 
+    // storageUsed = sum of fileSize of all assets in projects this user owns
     const users = await prisma.user.findMany({
       select: {
         id: true,
@@ -25,7 +26,16 @@ router.get('/', async (req: Request, res: Response) => {
         globalRole: true,
         avatarUrl: true,
         active: true,
+        storageQuota: true,
         createdAt: true,
+        // total storage used = sum of assets in projects this user owns
+        projects: {
+          select: {
+            assets: {
+              select: { fileSize: true },
+            },
+          },
+        },
         _count: {
           select: {
             memberships: true,
@@ -36,7 +46,17 @@ router.get('/', async (req: Request, res: Response) => {
       orderBy: { createdAt: 'desc' },
     });
 
-    res.json({ users });
+    // Compute storageUsed and ownedProjects from owned projects
+    const usersWithStorage = users.map(u => ({
+      ...u,
+      storageUsed: u.projects.reduce(
+        (sum, p) => sum + p.assets.reduce((s, a) => s + a.fileSize, 0),
+        0
+      ),
+      ownedProjects: u.projects.length,
+    }));
+
+    res.json({ users: usersWithStorage });
   } catch (err) {
     console.error('List users error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -77,6 +97,59 @@ router.get('/me', async (req: Request, res: Response) => {
   }
 });
 
+// PUT /api/users/:id/quota — update storage quota (admin only)
+router.put('/:id/quota', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.globalRole !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — admin only' });
+      return;
+    }
+
+    const { storageQuota } = req.body;
+
+    if (typeof storageQuota !== 'number' || !Number.isInteger(storageQuota) || storageQuota < 0) {
+      res.status(400).json({ error: 'storageQuota must be a non-negative integer (bytes)' });
+      return;
+    }
+
+    // Allow max 1 TB
+    const MAX_QUOTA = 1024 * 1024 * 1024 * 1024;
+    if (storageQuota > MAX_QUOTA) {
+      res.status(400).json({ error: `storageQuota cannot exceed ${MAX_QUOTA} bytes (1 TB)` });
+      return;
+    }
+
+    const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    await prisma.user.update({
+      where: { id: str(req.params.id) },
+      data: { storageQuota },
+    });
+
+    // Recalculate storageUsed from owned projects
+    const ownedAssets = await prisma.asset.findMany({
+      where: { project: { ownerId: str(req.params.id) } },
+      select: { fileSize: true },
+    });
+    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
+
+    res.json({
+      user: {
+        ...user,
+        storageQuota,
+        storageUsed,
+      },
+    });
+  } catch (err) {
+    console.error('Update quota error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // PUT /api/users/me — update current user profile
 router.put('/me', async (req: Request, res: Response) => {
   try {
@@ -154,9 +227,9 @@ router.put('/:id/role', async (req: Request, res: Response) => {
     }
 
     const { role } = req.body;
-    const validRoles = ['ADMIN', 'MEMBER'];
+    const validRoles = ['ADMIN', 'MEMBER', 'PROJECT_USER'];
     if (!validRoles.includes(role)) {
-      res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN or MEMBER' });
+      res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN, MEMBER, or PROJECT_USER' });
       return;
     }
 

+ 105 - 0
scripts/init-admin.sh

@@ -0,0 +1,105 @@
+#!/bin/sh
+# VidReview Init Script
+#   - FRESH: creates admin + locks registration + saves credentials
+#   - UPDATE: skips, leaves DB intact
+
+DB_HOST="${DB_HOST:-vidreview-db}"
+DB_NAME="${DB_NAME:-vidreview}"
+DB_USER="${DB_USER:-vidreview}"
+OUTPUT_DIR="${OUTPUT_DIR:-/seed-output}"
+ADMIN_EMAIL="${ADMIN_EMAIL:-admin@vidreview.local}"
+ADMIN_NAME="${ADMIN_NAME:-Admin}"
+API_CONTAINER="${API_CONTAINER:-vidreview-api}"
+
+run_psql() {
+  docker exec "$DB_HOST" psql -U "$DB_USER" -d "$DB_NAME" "$@" 2>&1
+}
+
+run_node() {
+  docker exec "$API_CONTAINER" node "$@" 2>&1
+}
+
+mkdir -p "$OUTPUT_DIR"
+
+echo "============================================================"
+echo "  VidReview Init Script"
+echo "============================================================"
+
+# Check if admin already exists
+echo ""
+echo "  Checking database..."
+
+COUNT_RAW=$(docker exec "$DB_HOST" psql -U "$DB_USER" -d "$DB_NAME" \
+  -t -c "SELECT COUNT(*) FROM \"User\" WHERE \"globalRole\"='ADMIN';" 2>&1)
+echo "  [debug] raw count: $COUNT_RAW"
+
+ADMIN_COUNT=$(echo "$COUNT_RAW" | tr -d '[:space:]' | grep -E '^[0-9]+$' || echo "")
+
+if [ -z "$ADMIN_COUNT" ]; then
+  echo "  ERROR: Could not read DB count."
+  echo "  Output was: $COUNT_RAW"
+  exit 1
+fi
+
+echo "  Admin users in DB: $ADMIN_COUNT"
+
+if [ "$ADMIN_COUNT" -gt 0 ]; then
+  echo ""
+  echo "  UPDATE DEPLOY: skipping admin creation."
+  echo "  DB already has an admin account."
+  echo ""
+  exit 0
+fi
+
+# FRESH DEPLOY
+echo ""
+echo "  FRESH DEPLOY: setting up initial account"
+
+RANDOM_PASS="vid-$(date +%s)-$(head -c 10 /dev/urandom | tr -dc 'a-z0-9')"
+echo "  Password generated."
+
+PASS_HASH=$(run_node -e "require('bcryptjs').hash('$RANDOM_PASS',10).then(h=>process.stdout.write(h)).catch(e=>{console.error(e);process.exit(1)})")
+if [ -z "$PASS_HASH" ]; then
+  echo "  ERROR: Could not generate bcrypt hash."
+  exit 1
+fi
+echo "  Hash generated."
+
+echo "  Locking user registration..."
+run_psql -c "INSERT INTO \"SiteSetting\" (id,name,value) VALUES (gen_random_uuid()::text, E'registration_enabled', E'false') ON CONFLICT (name) DO UPDATE SET value=E'false';"
+
+echo "  Creating admin account..."
+run_psql -c "INSERT INTO \"User\" (id,email,name,password,\"globalRole\",active,\"storageQuota\",\"storageUsed\",\"createdAt\",\"updatedAt\") VALUES (gen_random_uuid()::text, E'$ADMIN_EMAIL', E'$ADMIN_NAME', E'$PASS_HASH', E'ADMIN', true, 524288000, 0, NOW(), NOW());"
+
+CREDENTIALS_FILE="$OUTPUT_DIR/admin-credentials.txt"
+TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
+cat > "$CREDENTIALS_FILE" << 'HEREDOC'
+VidReview Admin Account - FRESH DEPLOY
+Generated: TIMESTAMP_PLACEHOLDER
+========================================================
+
+Email:    EMAIL_PLACEHOLDER
+Password: PASS_PLACEHOLDER
+
+Role:     ADMIN (full system access)
+
+Save this file securely. This is the only time
+the password is shown.
+
+========================================================
+HEREDOC
+
+sed -i "s/TIMESTAMP_PLACEHOLDER/$TIMESTAMP/" "$CREDENTIALS_FILE"
+sed -i "s/EMAIL_PLACEHOLDER/$ADMIN_EMAIL/" "$CREDENTIALS_FILE"
+sed -i "s/PASS_PLACEHOLDER/$RANDOM_PASS/" "$CREDENTIALS_FILE"
+
+echo ""
+echo "============================================================"
+echo "  Admin account created"
+echo "============================================================"
+echo ""
+echo "  Email:    $ADMIN_EMAIL"
+echo "  Password: $RANDOM_PASS"
+echo ""
+echo "  Credentials saved to: $CREDENTIALS_FILE"
+echo ""

+ 196 - 0
scripts/seed-mock-data.sql

@@ -0,0 +1,196 @@
+-- ============================================================
+-- VidReview Mock Data Seed Script
+-- Run with:
+--   docker exec -i vidreview-db psql -U vidreview -d vidreview < scripts/seed-mock-data.sql
+--
+-- Behaviour:
+--   * If DB already has users  -> skips entirely (update deploy safe)
+--   * If DB is empty           -> seeds full mock dataset
+-- ============================================================
+
+-- Guard: skip if users already exist
+DO $$
+BEGIN
+  IF EXISTS (SELECT 1 FROM "User" LIMIT 1) THEN
+    RAISE NOTICE E'Database already has users -- skipping seed (update deploy detected).\n';
+  ELSE
+    RAISE NOTICE 'Seeding mock data...';
+  END IF;
+END
+$$;
+
+-- Password hash for "demo1234" (bcrypt, $2a$10$...)
+-- Hash: docker exec vidreview-api node -e "require('bcryptjs').hash('demo1234',10).then(h=>console.log(h))"
+\set PASS_HASH '$2a$10$lnSHDKXHRTayh.Z6Sx/q7eHecpMywQqG5nWvTWThj6lw0h.a99gyO'
+\set ON_ERROR_STOP on
+
+-- Users
+INSERT INTO "User" (id, email, name, password, "globalRole", active, "storageQuota", "storageUsed", "createdAt", "updatedAt")
+VALUES (gen_random_uuid()::text, 'admin@vidreview.local', 'Admin', :'PASS_HASH', 'ADMIN', true, 524288000, 0, NOW(), NOW());
+
+INSERT INTO "User" (id, email, name, password, "globalRole", active, "storageQuota", "storageUsed", "createdAt", "updatedAt") VALUES
+  (gen_random_uuid()::text, 'alice@vidreview.local', 'Alice Johnson',  :'PASS_HASH', 'MEMBER', true, 524288000, 0, NOW(), NOW()),
+  (gen_random_uuid()::text, 'bob@vidreview.local',   'Bob Smith',        :'PASS_HASH', 'MEMBER', true, 524288000, 0, NOW(), NOW()),
+  (gen_random_uuid()::text, 'carol@vidreview.local', 'Carol White',     :'PASS_HASH', 'MEMBER', true, 524288000, 0, NOW(), NOW()),
+  (gen_random_uuid()::text, 'david@vidreview.local', 'David Lee',       :'PASS_HASH', 'MEMBER', true, 524288000, 0, NOW(), NOW()),
+  (gen_random_uuid()::text, 'eva@vidreview.local',   'Eva Martinez',    :'PASS_HASH', 'MEMBER', true, 524288000, 0, NOW(), NOW());
+
+-- Projects (each owned by one member)
+INSERT INTO "Project" (id, name, "ownerId", "createdAt", "updatedAt") VALUES
+  (gen_random_uuid()::text, 'Brand Campaign Q2',       (SELECT id FROM "User" WHERE email='alice@vidreview.local'), NOW(), NOW()),
+  (gen_random_uuid()::text, 'Product Launch Video',     (SELECT id FROM "User" WHERE email='alice@vidreview.local'), NOW(), NOW()),
+  (gen_random_uuid()::text, 'Internal Training Clips', (SELECT id FROM "User" WHERE email='bob@vidreview.local'),   NOW(), NOW()),
+  (gen_random_uuid()::text, 'Customer Testimonials',   (SELECT id FROM "User" WHERE email='bob@vidreview.local'),   NOW(), NOW()),
+  (gen_random_uuid()::text, 'Event Highlights Reel',   (SELECT id FROM "User" WHERE email='carol@vidreview.local'), NOW(), NOW()),
+  (gen_random_uuid()::text, 'Social Media Shorts',     (SELECT id FROM "User" WHERE email='david@vidreview.local'), NOW(), NOW()),
+  (gen_random_uuid()::text, 'How-To Tutorial Series',  (SELECT id FROM "User" WHERE email='eva@vidreview.local'),  NOW(), NOW()),
+  (gen_random_uuid()::text, 'Partner Collaboration',   (SELECT id FROM "User" WHERE email='carol@vidreview.local'), NOW(), NOW());
+
+-- Project Members
+-- Brand Campaign Q2  (owner=Alice, ADMIN)
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='alice@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Brand Campaign Q2';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='bob@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Brand Campaign Q2';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Brand Campaign Q2';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='david@vidreview.local'), id, 'VIEWER', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Brand Campaign Q2';
+
+-- Product Launch Video
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='alice@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Product Launch Video';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='eva@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Product Launch Video';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='alice@vidreview.local')
+  FROM "Project" WHERE name='Product Launch Video';
+
+-- Internal Training Clips
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='bob@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Internal Training Clips';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='alice@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Internal Training Clips';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='david@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Internal Training Clips';
+
+-- Customer Testimonials
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='bob@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Customer Testimonials';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Customer Testimonials';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='eva@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='bob@vidreview.local')
+  FROM "Project" WHERE name='Customer Testimonials';
+
+-- Event Highlights Reel
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Event Highlights Reel';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='bob@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Event Highlights Reel';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='alice@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Event Highlights Reel';
+
+-- Social Media Shorts
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='david@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='david@vidreview.local')
+  FROM "Project" WHERE name='Social Media Shorts';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='alice@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='david@vidreview.local')
+  FROM "Project" WHERE name='Social Media Shorts';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='bob@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='david@vidreview.local')
+  FROM "Project" WHERE name='Social Media Shorts';
+
+-- How-To Tutorial Series
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='eva@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='eva@vidreview.local')
+  FROM "Project" WHERE name='How-To Tutorial Series';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='eva@vidreview.local')
+  FROM "Project" WHERE name='How-To Tutorial Series';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='david@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='eva@vidreview.local')
+  FROM "Project" WHERE name='How-To Tutorial Series';
+
+-- Partner Collaboration
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='carol@vidreview.local'), id, 'ADMIN', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Partner Collaboration';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='david@vidreview.local'), id, 'EDITOR', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Partner Collaboration';
+
+INSERT INTO "ProjectMember" ("id","userId","projectId","role","joinedAt","invitedBy")
+  SELECT gen_random_uuid()::text, (SELECT id FROM "User" WHERE email='eva@vidreview.local'), id, 'REVIEWER', NOW(), (SELECT id FROM "User" WHERE email='carol@vidreview.local')
+  FROM "Project" WHERE name='Partner Collaboration';
+
+-- Output summary
+\pset format aligned
+\pset border 2
+
+\echo ''
+\echo '============================================================'
+\echo '  VidReview Mock Data Seeded Successfully'
+\echo '============================================================'
+\echo ''
+\echo '  Password (all accounts): demo1234'
+\echo '  Storage quota:          500 MB per user'
+\echo ''
+
+\echo '-- Users ----------------------------------------------------'
+SELECT
+  "globalRole"::text                              AS "Role",
+  name                                             AS "Name",
+  email                                            AS "Email",
+  ("storageQuota"::bigint / 1024 / 1024) || ' MB' AS "Quota"
+FROM "User"
+ORDER BY
+  CASE "globalRole" WHEN 'ADMIN' THEN 0 WHEN 'MEMBER' THEN 1 ELSE 2 END,
+  name;
+
+\echo ''
+\echo '-- Projects & Members ----------------------------------------'
+SELECT
+  pr.name                       AS "Project",
+  u.name                        AS "Member",
+  pm.role::text                 AS "Role",
+  CASE WHEN pr."ownerId" = u.id THEN '(owner)' ELSE '' END AS "Note"
+FROM "Project" pr
+JOIN "ProjectMember" pm ON pm."projectId" = pr.id
+JOIN "User" u ON u.id = pm."userId"
+ORDER BY pr.name, u.name;
+
+\echo ''
+\echo '============================================================'
+\echo ''

+ 170 - 0
scripts/seed-mock-data.ts

@@ -0,0 +1,170 @@
+#!/usr/bin/env node
+/**
+ * Seed script — injects mock data into the VidReview database.
+ * Run via Docker so Prisma client binary matches the environment.
+ *
+ * Usage:
+ *   npm run seed         # via package.json script
+ *   docker compose run --rm api npx tsx scripts/seed-mock-data.ts
+ *
+ * Env vars (optional, defaults work for local docker):
+ *   DATABASE_URL=postgresql://vidreview:vidreview123@localhost:5432/vidreview
+ */
+
+import { PrismaClient } from '@prisma/client';
+import bcrypt from 'bcryptjs';
+
+const prisma = new PrismaClient();
+
+const BCRYPT_ROUNDS = 10;
+const PASSWORD = 'demo1234';
+
+async function cleanAll() {
+  console.log('\n🧹 Cleaning existing data...');
+  await prisma.comment.deleteMany();
+  await prisma.asset.deleteMany();
+  await prisma.invitation.deleteMany();
+  await prisma.projectMember.deleteMany();
+  await prisma.project.deleteMany();
+  await prisma.user.deleteMany();
+  console.log('   All tables cleared.');
+}
+
+async function createUser(email: string, name: string, globalRole: 'ADMIN' | 'MEMBER', projectRole?: 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER') {
+  const password = await bcrypt.hash(PASSWORD, BCRYPT_ROUNDS);
+  return prisma.user.create({
+    data: { email, name, password, globalRole, storageQuota: 524288000, storageUsed: 0 },
+    select: { id: true, email: true, name: true, globalRole: true },
+  });
+}
+
+async function createProject(name: string, ownerId: string, editorId: string, reviewerId: string) {
+  const slug = name.toLowerCase().replace(/\s+/g, '-');
+
+  // Create project
+  const project = await prisma.project.create({
+    data: {
+      name,
+      ownerId,
+    },
+  });
+
+  // Add owner as ADMIN of project
+  await prisma.projectMember.create({
+    data: { userId: ownerId, projectId: project.id, role: 'ADMIN', invitedBy: ownerId },
+  });
+
+  // Add editor
+  await prisma.projectMember.create({
+    data: { userId: editorId, projectId: project.id, role: 'EDITOR', invitedBy: ownerId },
+  });
+
+  // Add reviewer
+  await prisma.projectMember.create({
+    data: { userId: reviewerId, projectId: project.id, role: 'REVIEWER', invitedBy: ownerId },
+  });
+
+  return project;
+}
+
+async function main() {
+  console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+  console.log('  VidReview — Mock Data Seeder');
+  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
+
+  await cleanAll();
+
+  // ── 1. Admin ──────────────────────────────────────────────────────────────
+  const admin = await createUser('admin@vidreview.local', 'Admin', 'ADMIN');
+  console.log(`✅ Admin created`);
+
+  // ── 2. Members ────────────────────────────────────────────────────────────
+  const memberDefs = [
+    { email: 'alice@vidreview.local', name: 'Alice Johnson' },
+    { email: 'bob@vidreview.local',    name: 'Bob Smith'     },
+    { email: 'carol@vidreview.local',  name: 'Carol White'   },
+  ];
+
+  const members = await Promise.all(
+    memberDefs.map(m => createUser(m.email, m.name, 'MEMBER'))
+  );
+  console.log(`✅ ${members.length} members created`);
+
+  // ── 3. Projects per member ─────────────────────────────────────────────────
+  const projectDefs = [
+    // Alice owns 3 projects
+    { name: 'Brand Campaign Q2',       ownerIdx: 0, editorIdx: 1, reviewerIdx: 2 },
+    { name: 'Product Launch Video',    ownerIdx: 0, editorIdx: 2, reviewerIdx: 1 },
+    { name: 'Internal Training Clips', ownerIdx: 0, editorIdx: 1, reviewerIdx: 2 },
+
+    // Bob owns 2 projects
+    { name: 'Customer Testimonials',   ownerIdx: 1, editorIdx: 0, reviewerIdx: 2 },
+    { name: 'Event Highlights Reel',    ownerIdx: 1, editorIdx: 2, reviewerIdx: 0 },
+
+    // Carol owns 3 projects
+    { name: 'Social Media Shorts',     ownerIdx: 2, editorIdx: 0, reviewerIdx: 1 },
+    { name: 'How-To Tutorial Series',  ownerIdx: 2, editorIdx: 1, reviewerIdx: 0 },
+    { name: 'Partner Collaboration',   ownerIdx: 2, editorIdx: 0, reviewerIdx: 1 },
+  ];
+
+  const projects = await Promise.all(
+    projectDefs.map(p =>
+      createProject(
+        p.name,
+        members[p.ownerIdx].id,
+        members[p.editorIdx].id,
+        members[p.reviewerIdx].id,
+      )
+    )
+  );
+  console.log(`✅ ${projects.length} projects created\n`);
+
+  // ── Output credentials table ───────────────────────────────────────────────
+  const divider = '─'.repeat(72);
+  console.log(divider);
+  console.log('  📋  Login Credentials');
+  console.log(divider);
+  console.log(`  ${'Type'.padEnd(10)} ${'Name'.padEnd(22)} ${'Email'.padEnd(35)} Password`);
+  console.log(divider);
+  console.log(`  ${'ADMIN'.padEnd(10)} ${admin.name.padEnd(22)} ${admin.email.padEnd(35)} ${PASSWORD}`);
+  for (const m of members) {
+    console.log(`  ${'MEMBER'.padEnd(10)} ${m.name.padEnd(22)} ${m.email.padEnd(35)} ${PASSWORD}`);
+  }
+  console.log(divider);
+  console.log(`\n  All users have storage quota: 500 MB`);
+  console.log(`  Password for all accounts: ${PASSWORD}`);
+  console.log('');
+  console.log('  Project membership per user:');
+  console.log(divider);
+  for (const m of members) {
+    const memberProjects = projects.filter((_, i) => projectDefs[i].ownerIdx === members.indexOf(m));
+    const roleMap: Record<string, string> = {};
+    for (const proj of memberProjects) {
+      const def = projectDefs.find(p => p.name === proj.name)!;
+      const role = members[projectDefs.findIndex(p => p.name === proj.name)].id === m.id
+        ? 'owner → project ADMIN'
+        : `project ${projectDefs.findIndex(p => p.name === proj.name) < 3 ? ['EDITOR', 'REVIEWER', 'EDITOR'][projectDefs.findIndex(p => p.name === proj.name) % 3] : ['REVIEWER', 'EDITOR', 'REVIEWER'][projectDefs.findIndex(p => p.name === proj.name) % 3]}`;
+    }
+    const memberships = await prisma.projectMember.findMany({
+      where: { userId: m.id },
+      include: { project: true },
+    });
+    const roles = await prisma.projectMember.findMany({ where: { userId: m.id } });
+    const lines = memberships.map((pm, i) => {
+      const def = projectDefs.find(p => p.name === pm.project.name)!;
+      const role = pm.userId === members[def.ownerIdx].id ? 'ADMIN (owner)' : roles[i].role;
+      return `    • ${pm.project.name.padEnd(30)} [${role}]`;
+    });
+    console.log(`  ${m.name} (${m.email}):`);
+    lines.forEach(l => console.log(l));
+  }
+  console.log(divider);
+  console.log('\n✅ Done!\n');
+
+  await prisma.$disconnect();
+}
+
+main().catch(err => {
+  console.error('\n❌ Seed failed:', err);
+  process.exit(1);
+});

+ 21 - 0
scripts/seed.sh

@@ -0,0 +1,21 @@
+#!/bin/sh
+# Seed VidReview mock data
+#   - Checks if DB is empty first
+#   - Only seeds if no users exist
+#   - Safe to run on update deploys
+
+DB_HOST="${DB_HOST:-vidreview-db}"
+DB_NAME="${DB_NAME:-vidreview}"
+DB_USER="${DB_USER:-vidreview}"
+
+echo "Checking if DB needs seeding..."
+COUNT=$(docker exec "$DB_HOST" psql -U "$DB_USER" -d "$DB_NAME" \
+  -t -c "SELECT COUNT(*) FROM \"User\";" 2>/dev/null | tr -d '[:space:]')
+
+if [ -z "$COUNT" ] || [ "$COUNT" -gt 0 ]; then
+  echo "DB already has $COUNT user(s) -- skipping seed."
+  exit 0
+fi
+
+echo "Seeding mock data..."
+docker exec -i "$DB_HOST" psql -U "$DB_USER" -d "$DB_NAME" < /scripts/seed-mock-data.sql

+ 12 - 1
src/app/(auth)/login/page.tsx

@@ -10,7 +10,7 @@ function LoginForm() {
   const router = useRouter();
   const searchParams = useSearchParams();
   const inviteToken = searchParams.get('invite_token');
-  const { login, acceptedProjects, clearAcceptedProjects } = useAuth();
+  const { login, acceptedProjects, clearAcceptedProjects, user } = useAuth();
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
   const [error, setError] = useState('');
@@ -34,6 +34,17 @@ function LoginForm() {
     }
   }, [acceptedProjects, justJoined]);
 
+  // Redirect if already logged in
+  useEffect(() => {
+    if (user) {
+      if (inviteToken) {
+        router.push(`/invite/${inviteToken}`);
+      } else {
+        router.push('/projects');
+      }
+    }
+  }, [user, inviteToken, router]);
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     setError('');

+ 13 - 2
src/app/(auth)/register/page.tsx

@@ -10,7 +10,7 @@ function RegisterForm() {
   const router = useRouter();
   const searchParams = useSearchParams();
   const inviteToken = searchParams.get('invite_token');
-  const { register, acceptedProjects, clearAcceptedProjects, justRegisteredName } = useAuth();
+  const { register, acceptedProjects, clearAcceptedProjects, justRegisteredName, user } = useAuth();
   const [name, setName] = useState('');
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
@@ -40,6 +40,17 @@ function RegisterForm() {
     }
   }, [acceptedProjects, justJoined]);
 
+  // Redirect if already logged in
+  useEffect(() => {
+    if (user) {
+      if (inviteToken) {
+        router.push(`/invite/${inviteToken}`);
+      } else {
+        router.push('/projects');
+      }
+    }
+  }, [user, inviteToken, router]);
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     setError('');
@@ -49,7 +60,7 @@ function RegisterForm() {
     }
     setLoading(true);
     try {
-      await register(email, name, password);
+      await register(email, name, password, inviteToken ?? undefined);
       if (inviteToken) {
         router.push(`/invite/${inviteToken}`);
       } else {

+ 97 - 90
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -51,7 +51,7 @@ export default function ProjectDetailPage() {
   const [uploading, setUploading] = useState(false);
   const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
 
-  // Invite form state
+  // Invite form state (single shared form)
   const [inviteEmail, setInviteEmail] = useState('');
   const [inviteRole, setInviteRole] = useState('REVIEWER');
   const [inviting, setInviting] = useState(false);
@@ -110,6 +110,10 @@ export default function ProjectDetailPage() {
   const handleInvite = async (e: React.FormEvent) => {
     e.preventDefault();
     if (!token || !inviteEmail.trim()) return;
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
+      setInviteError('Invalid email address');
+      return;
+    }
     setInviting(true);
     setInviteError('');
     setInviteSuccess('');
@@ -140,9 +144,9 @@ export default function ProjectDetailPage() {
       const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
       const { invitations } = await invitationsApi.list(token, projectId);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
-      const fullUrl = `${window.location.origin}${inviteUrl}`;
-      await safeCopy(fullUrl);
-      setCreatedLink(fullUrl);
+      // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
+      await safeCopy(inviteUrl);
+      setCreatedLink(inviteUrl);
       setInviteEmail('');
     } catch (err: any) {
       const msg = err instanceof Error ? err.message : String(err);
@@ -349,9 +353,23 @@ export default function ProjectDetailPage() {
         <div className="w-px h-4" style={{ background: 'rgba(255,255,255,0.10)' }} />
 
         <div className="flex-1 min-w-0">
-          <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
-            {project?.name}
-          </h1>
+          <div className="flex items-center gap-2">
+            <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
+              {project?.name}
+            </h1>
+            {canManage && (
+              <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
+                    style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
+                {isAdmin ? 'Owner' : 'Editor'}
+              </span>
+            )}
+            {!canManage && !isAdmin && (
+              <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
+                    style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
+                {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
+              </span>
+            )}
+          </div>
           {project?.description && (
             <p className="text-xs truncate mt-0.5" style={{ color: 'var(--text-muted)' }}>
               {project.description}
@@ -403,43 +421,58 @@ export default function ProjectDetailPage() {
         {/* ── Videos Tab ───────────────────────────────────────────────────── */}
         {activeTab === 'videos' && (
           <>
-            {/* Upload zone */}
-            <div
-              {...getRootProps()}
-              className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
-              style={{
-                background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
-                border: isDragActive
-                  ? '1px solid rgba(99,102,241,0.40)'
-                  : '1px dashed rgba(255,255,255,0.10)',
-                borderRadius: '16px',
-              }}
-            >
-              <input {...getInputProps()} />
+            {/* Upload zone — only shown to EDITOR and ADMIN */}
+            {canManage ? (
+              <div
+                {...getRootProps()}
+                className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
+                style={{
+                  background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
+                  border: isDragActive
+                    ? '1px solid rgba(99,102,241,0.40)'
+                    : '1px dashed rgba(255,255,255,0.10)',
+                  borderRadius: '16px',
+                }}
+              >
+                <input {...getInputProps()} />
 
-              {uploading ? (
-                <div className="space-y-3">
-                  <div className="w-9 h-9 rounded-full mx-auto animate-spin"
-                       style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
-                  <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
-                </div>
-              ) : (
-                <>
-                  <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
-                       style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
-                    <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
-                      <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
-                    </svg>
+                {uploading ? (
+                  <div className="space-y-3">
+                    <div className="w-9 h-9 rounded-full mx-auto animate-spin"
+                         style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
+                    <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
                   </div>
-                  <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
-                    {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
-                  </p>
-                  <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
-                    MP4, MOV, WebM — up to 500MB each
-                  </p>
-                </>
-              )}
-            </div>
+                ) : (
+                  <>
+                    <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+                         style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
+                      <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+                      </svg>
+                    </div>
+                    <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+                      {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
+                    </p>
+                    <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                      MP4, MOV, WebM — up to 500MB each
+                    </p>
+                  </>
+                )}
+              </div>
+            ) : (
+              <div className="mb-8 rounded-2xl p-6 text-center animate-fade-in"
+                   style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
+                <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
+                     style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
+                  <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+                  </svg>
+                </div>
+                <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                  Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.
+                </p>
+              </div>
+            )}
 
             {/* Asset grid */}
             {assets.length === 0 ? (
@@ -651,7 +684,7 @@ export default function ProjectDetailPage() {
         {activeTab === 'members' && (
           <div className="max-w-3xl animate-fade-in">
 
-            {/* Invite form */}
+            {/* Invite form — single form, shared email + role */}
             {canManage && (
               <div className="card p-5 mb-6">
                 <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
@@ -659,56 +692,20 @@ export default function ProjectDetailPage() {
                 </h2>
 
                 <div className="space-y-3">
-                  <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
+                  <form
+                    onSubmit={e => { e.preventDefault(); handleInvite(e); }}
+                    className="flex items-end gap-3 flex-wrap"
+                  >
                     <div className="flex-1 min-w-[180px]">
-                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
+                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
+                        Email address
+                      </label>
                       <input
                         type="email"
                         className="input"
                         value={inviteEmail}
                         onChange={e => setInviteEmail(e.target.value)}
                         placeholder="colleague@company.com"
-                        required
-                      />
-                    </div>
-                    <div className="w-36">
-                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
-                      <select
-                        className="input"
-                        value={inviteRole}
-                        onChange={e => setInviteRole(e.target.value)}
-                      >
-                        {Object.entries(ROLE_LABELS).map(([value, label]) => (
-                          <option key={value} value={value}>{label}</option>
-                        ))}
-                      </select>
-                    </div>
-                    <button
-                      type="submit"
-                      disabled={inviting}
-                      className="btn btn-primary btn-md"
-                    >
-                      {inviting ? 'Sending…' : 'Send invite'}
-                    </button>
-                  </form>
-
-                  {/* Or separator */}
-                  <div className="flex items-center gap-3">
-                    <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
-                    <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>or</span>
-                    <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
-                  </div>
-
-                  {/* Create & copy link */}
-                  <div className="flex items-end gap-3 flex-wrap">
-                    <div className="flex-1 min-w-[180px]">
-                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email for the link</label>
-                      <input
-                        type="email"
-                        className="input"
-                        value={inviteEmail}
-                        onChange={e => setInviteEmail(e.target.value)}
-                        placeholder="Paste their email here"
                       />
                     </div>
                     <div className="w-36">
@@ -723,22 +720,32 @@ export default function ProjectDetailPage() {
                         ))}
                       </select>
                     </div>
+                    {/* Both buttons share the same email + role from this single form */}
                     <button
                       type="button"
                       disabled={inviting || !inviteEmail.trim()}
                       onClick={handleCreateLink}
                       className="btn btn-secondary btn-md"
+                      title="Create invite link and copy to clipboard"
                     >
                       {inviting ? 'Creating…' : (
                         <span className="flex items-center gap-1.5">
                           <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                             <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
                           </svg>
-                          Create & Copy Link
+                          Copy Link
                         </span>
                       )}
                     </button>
-                  </div>
+                    <button
+                      type="submit"
+                      disabled={inviting || !inviteEmail.trim()}
+                      className="btn btn-primary btn-md"
+                      title="Send invite — link is included automatically"
+                    >
+                      {inviting ? 'Sending…' : 'Send Invite'}
+                    </button>
+                  </form>
 
                   {/* Created link feedback */}
                   {createdLink && (
@@ -748,9 +755,9 @@ export default function ProjectDetailPage() {
                         <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                           <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                         </svg>
-                        <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
+                        <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
                       </div>
-                      <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>
+                      <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
                         {createdLink}
                       </p>
                     </div>

+ 19 - 4
src/app/(dashboard)/projects/page.tsx

@@ -271,10 +271,25 @@ function ProjectCard({ project, index, onInvite }: {
             </svg>
           </div>
           <div className="min-w-0">
-            <h3 className="text-sm font-semibold truncate transition-colors"
-                style={{ color: 'var(--text)' }}>
-              {project.name}
-            </h3>
+            <div className="flex items-center gap-2 flex-wrap">
+              <h3 className="text-sm font-semibold truncate transition-colors"
+                  style={{ color: 'var(--text)' }}>
+                {project.name}
+              </h3>
+              {project.myRole && (
+                <span
+                  className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${
+                    project.myRole === 'OWNER' ? 'badge-owner' :
+                    project.myRole === 'ADMIN'   ? 'badge-danger' :
+                    project.myRole === 'EDITOR'  ? 'badge-brand' :
+                    project.myRole === 'REVIEWER'? 'badge-muted' :
+                    'badge-subtle'
+                  }`}
+                >
+                  {project.myRole === 'OWNER' ? 'Owner' : project.myRole}
+                </span>
+              )}
+            </div>
             {project.description && (
               <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>
                 {project.description}

+ 213 - 77
src/app/(dashboard)/users/page.tsx

@@ -2,34 +2,88 @@
 
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useAuth } from '@/lib/auth-context';
-import { usersApi, projectsApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api';
-
+import { usersApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api';
+
+/**
+ * Global workspace roles (stored in User.globalRole):
+ *
+ *  ADMIN        — Full system access. Manage all users, all projects, quotas, settings.
+ *                 Sees every project regardless of membership.
+ *
+ *  MEMBER       — Regular registered user. Can create their own projects,
+ *                 invite members, upload videos. Storage quota applies to owned projects.
+ *
+ *  PROJECT_USER — Invited-only user. Has no workspace presence beyond their invitations.
+ *                 Cannot create projects. No storage quota (no owned projects).
+ *
+ *  (Project-level roles in ProjectMember.role:
+ *   ADMIN | EDITOR | REVIEWER | VIEWER — scoped to a specific project.)
+ */
 const GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
-  ADMIN:  { label: 'Admin',  badge: 'badge-danger' },
-  MEMBER: { label: 'Member', badge: 'badge-muted' },
-};
-
-const INVITE_ROLE_LABELS: Record<string, string> = {
-  ADMIN:   'Admin',
-  EDITOR:  'Editor',
-  REVIEWER:'Reviewer',
-  VIEWER:  'Viewer',
+  ADMIN:        { label: 'Admin',         badge: 'badge-danger' },
+  MEMBER:       { label: 'Member',        badge: 'badge-muted' },
+  PROJECT_USER: { label: 'Project User', badge: 'badge-subtle' },
 };
 
 export default function UsersPage() {
   const { user: currentUser, token } = useAuth();
   const [users, setUsers] = useState<AdminUser[]>([]);
   const [invitations, setInvitations] = useState<AdminInvitation[]>([]);
-  const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
   const [loading, setLoading] = useState(true);
   const [updating, setUpdating] = useState<string | null>(null);
   const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
+
+  // Quota edit
+  const [editingQuota, setEditingQuota] = useState<string | null>(null);
+  const [quotaInput, setQuotaInput] = useState('');
+  const [quotaUnit, setQuotaUnit] = useState<'MB' | 'GB'>('MB');
+  const [quotaError, setQuotaError] = useState('');
+
+  const formatBytes = (bytes: number): string => {
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
+  };
+
+  const parseBytes = (value: string, unit: 'MB' | 'GB'): number => {
+    const v = parseFloat(value);
+    if (unit === 'GB') return Math.round(v * 1024 * 1024 * 1024);
+    return Math.round(v * 1024 * 1024);
+  };
+
+  const openQuotaEdit = (u: AdminUser) => {
+    const mb = (u.storageQuota / 1024 / 1024).toFixed(0);
+    setQuotaInput(mb);
+    setQuotaUnit('MB');
+    setQuotaError('');
+    setEditingQuota(u.id);
+  };
+
+  const handleQuotaSave = async (userId: string) => {
+    const parsed = parseFloat(quotaInput);
+    if (isNaN(parsed) || parsed < 1) {
+      setQuotaError('Enter a value ≥ 1');
+      return;
+    }
+    setUpdating(userId);
+    setQuotaError('');
+    try {
+      const bytes = parseBytes(quotaInput, quotaUnit);
+      const { user: updated } = await usersApi.updateQuota(token!, userId, bytes);
+      setUsers(prev => prev.map(u => u.id === userId
+        ? { ...u, storageQuota: updated.storageQuota, storageUsed: updated.storageUsed }
+        : u));
+      setEditingQuota(null);
+    } catch (err) {
+      setQuotaError(err instanceof Error ? err.message : 'Failed to update quota');
+    } finally {
+      setUpdating(null);
+    }
+  };
   const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users');
 
-  // Invite form
+  // Invite form — workspace MEMBER invite (email only)
   const [inviteEmail, setInviteEmail] = useState('');
-  const [inviteProject, setInviteProject] = useState('');
-  const [inviteRole, setInviteRole] = useState('REVIEWER');
   const [inviting, setInviting] = useState(false);
   const [inviteError, setInviteError] = useState('');
   const [inviteSuccess, setInviteSuccess] = useState('');
@@ -55,12 +109,8 @@ export default function UsersPage() {
   const loadInvitations = useCallback(async () => {
     if (!token || !isAdmin) return;
     try {
-      const [{ invitations: inv }, { projects: proj }] = await Promise.all([
-        invitationsApi.listAll(token),
-        projectsApi.list(token),
-      ]);
+      const { invitations: inv } = await invitationsApi.listAll(token);
       setInvitations(inv);
-      setProjects(proj.map((p: any) => ({ id: p.id, name: p.name })));
       // Cache invite URLs
       for (const i of inv) {
         inviteUrlMap.current[i.token] = `${window.location.origin}/invite/${i.token}`;
@@ -78,36 +128,39 @@ export default function UsersPage() {
     else loadInvitations();
   }, [token, isAdmin, activeTab, loadUsers, loadInvitations]);
 
-  // ── Send invite ──────────────────────────────────────────────────────────────
+  // ── Send workspace invite ──────────────────────────────────────────────────────
   const handleInvite = async (e: React.FormEvent) => {
     e.preventDefault();
-    if (!token || !inviteEmail.trim() || !inviteProject) return;
+    if (!token || !inviteEmail.trim()) return;
     setInviting(true);
     setInviteError('');
     setInviteSuccess('');
     setCreatedLink('');
     try {
-      const { inviteUrl } = await invitationsApi.adminInvite(token, inviteEmail.trim(), inviteProject, inviteRole);
-      const fullUrl = `${window.location.origin}${inviteUrl}`;
-      inviteUrlMap.current[inviteUrl.split('/').pop()!] = fullUrl;
-      setInvitations(prev => [...invitations, {
+      // Workspace invite → POST /api/invitations/workspace → creates MEMBER user
+      const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim());
+      const fullUrl = inviteUrl;
+      const tokenPart = inviteUrl.split('/invite/').pop()!;
+      inviteUrlMap.current[tokenPart] = fullUrl;
+      // Optimistically add to list — backend returns the real invitation
+      setInvitations(prev => [...prev, {
         id: Math.random().toString(),
         email: inviteEmail.trim(),
-        projectId: inviteProject,
-        role: inviteRole,
-        token: inviteUrl.split('/').pop()!,
+        projectId: '',
+        role: 'MEMBER',
+        token: tokenPart,
         status: 'PENDING',
         invitedBy: null,
         expiresAt: '',
         createdAt: new Date().toISOString(),
-        project: projects.find(p => p.id === inviteProject)!,
+        project: { id: '', name: 'Workspace' },
+        type: 'WORKSPACE',
       }]);
       setInviteEmail('');
-      setInviteProject('');
       setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
       await navigator.clipboard.writeText(fullUrl).catch(() => {});
       setCreatedLink(fullUrl);
-      setTimeout(() => setInviteSuccess(''), 4000);
+      setTimeout(() => setInviteSuccess(''), 6000);
     } catch (err) {
       setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
     } finally {
@@ -131,6 +184,7 @@ export default function UsersPage() {
 
   // ── Copy link ────────────────────────────────────────────────────────────────
   const handleCopy = async (inv: AdminInvitation) => {
+    // Build full URL: either from cache or construct from known origin
     const url = inviteUrlMap.current[inv.token] ?? `${window.location.origin}/invite/${inv.token}`;
     await navigator.clipboard.writeText(url).catch(() => {});
     setCopiedId(inv.id);
@@ -143,7 +197,9 @@ export default function UsersPage() {
     setUpdating(userId);
     try {
       const { user: updated } = await usersApi.updateRole(token, userId, globalRole);
-      setUsers(prev => prev.map(u => u.id === userId ? { ...u, globalRole: (updated as any).globalRole ?? globalRole } : u));
+      setUsers(prev => prev.map(u => u.id === userId
+        ? { ...u, globalRole: (updated as any).globalRole ?? globalRole }
+        : u));
     } catch (err) {
       alert(err instanceof Error ? err.message : 'Failed to update role');
     } finally {
@@ -268,11 +324,103 @@ export default function UsersPage() {
 
                       {/* Stats */}
                       <div className="hidden sm:flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
-                        <span>{u._count?.memberships ?? 0} projects</span>
+                        <span>{u.ownedProjects ?? 0} owned</span>
+                        <span>{((u._count?.memberships ?? 0) - (u.ownedProjects ?? 0))} shared</span>
                         <span>{u._count?.comments ?? 0} comments</span>
                         <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
+
+                        {/* Storage bar — only for users with owned projects */}
+                        {u.ownedProjects > 0 && (
+                          <div className="flex items-center gap-2">
+                            <div className="relative w-16 h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
+                              <div
+                                className="absolute left-0 top-0 h-full rounded-full transition-all"
+                                style={{
+                                  width: `${Math.min(100, Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100))}%`,
+                                  background: u.storageUsed >= u.storageQuota ? '#F87171' : '#6366F1',
+                                }}
+                              />
+                            </div>
+                            <span className="whitespace-nowrap" style={{ color: u.storageUsed >= u.storageQuota ? '#F87171' : undefined }}>
+                              {formatBytes(u.storageUsed)} / {formatBytes(u.storageQuota)}
+                            </span>
+                          </div>
+                        )}
+                        {u.ownedProjects === 0 && (
+                          <span style={{ color: 'var(--text-subtle)' }}>—</span>
+                        )}
                       </div>
 
+                      {/* Inline quota editor */}
+                      {editingQuota === u.id && (
+                        <div className="w-full shrink-0 animate-fade-in"
+                             style={{
+                               background: 'rgba(99,102,241,0.06)',
+                               border: '1px solid rgba(99,102,241,0.20)',
+                               borderRadius: '8px',
+                               padding: '10px 14px',
+                             }}>
+                          <div className="flex items-center gap-2 flex-wrap">
+                            <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#A5B4FC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
+                            </svg>
+                            <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
+                              Quota for {u.name}:
+                            </span>
+                            <div className="flex items-center gap-1">
+                              <input
+                                type="number"
+                                min="1"
+                                step="1"
+                                className="input text-xs py-1 w-20"
+                                value={quotaInput}
+                                onChange={e => setQuotaInput(e.target.value)}
+                                onKeyDown={e => e.key === 'Enter' && handleQuotaSave(u.id)}
+                                autoFocus
+                              />
+                              <select
+                                className="input text-xs py-1"
+                                value={quotaUnit}
+                                onChange={e => setQuotaUnit(e.target.value as 'MB' | 'GB')}
+                              >
+                                <option value="MB">MB</option>
+                                <option value="GB">GB</option>
+                              </select>
+                            </div>
+                            <button
+                              onClick={() => handleQuotaSave(u.id)}
+                              disabled={updating === u.id}
+                              className="btn btn-primary btn-sm text-xs"
+                            >
+                              Save
+                            </button>
+                            <button
+                              onClick={() => { setEditingQuota(null); setQuotaError(''); }}
+                              className="btn btn-secondary btn-sm text-xs"
+                            >
+                              Cancel
+                            </button>
+                            {quotaError && (
+                              <span className="text-xs" style={{ color: '#F87171' }}>{quotaError}</span>
+                            )}
+                          </div>
+                          <div className="mt-1.5">
+                            <div className="relative w-full h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
+                              <div
+                                className="absolute left-0 top-0 h-full rounded-full"
+                                style={{
+                                  width: `${Math.min(100, Math.round((u.storageUsed / Math.max(parseBytes(quotaInput, quotaUnit), 1)) * 100))}%`,
+                                  background: u.storageUsed >= parseBytes(quotaInput, quotaUnit) ? '#F87171' : '#6366F1',
+                                }}
+                              />
+                            </div>
+                            <p className="text-[10px] mt-1" style={{ color: 'var(--text-subtle)' }}>
+                              Currently used: {formatBytes(u.storageUsed)} ({Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100)}%)
+                            </p>
+                          </div>
+                        </div>
+                      )}
+
                       {/* Role selector */}
                       <div className="shrink-0">
                         <select
@@ -291,6 +439,16 @@ export default function UsersPage() {
                       {/* Actions */}
                       {!isMe && (
                         <div className="flex items-center gap-1 shrink-0">
+                          <button
+                            onClick={() => openQuotaEdit(u)}
+                            disabled={updating === u.id}
+                            className="btn btn-secondary btn-sm"
+                            title="Edit storage quota"
+                          >
+                            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
+                            </svg>
+                          </button>
                           <button
                             onClick={() => handleToggleActive(u.id, u.active)}
                             disabled={updating === u.id}
@@ -336,9 +494,11 @@ export default function UsersPage() {
               </h2>
 
               <form onSubmit={handleInvite} className="space-y-4">
-                <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
-                  <div className="sm:col-span-2">
-                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
+                <div className="flex items-end gap-3">
+                  <div className="flex-1">
+                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
+                      Invite email address
+                    </label>
                     <input
                       type="email"
                       className="input"
@@ -348,41 +508,7 @@ export default function UsersPage() {
                       required
                     />
                   </div>
-                  <div>
-                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
-                    <select
-                      className="input"
-                      value={inviteRole}
-                      onChange={e => setInviteRole(e.target.value)}
-                    >
-                      {Object.entries(INVITE_ROLE_LABELS).map(([v, l]) => (
-                        <option key={v} value={v}>{l}</option>
-                      ))}
-                    </select>
-                  </div>
-                </div>
-
-                <div>
-                  <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Project</label>
-                  <select
-                    className="input"
-                    value={inviteProject}
-                    onChange={e => setInviteProject(e.target.value)}
-                    required
-                  >
-                    <option value="">Select a project…</option>
-                    {projects.map(p => (
-                      <option key={p.id} value={p.id}>{p.name}</option>
-                    ))}
-                  </select>
-                </div>
-
-                {inviteError && (
-                  <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
-                )}
-
-                <div className="flex items-center gap-3">
-                  <button type="submit" disabled={inviting || !inviteEmail.trim() || !inviteProject} className="btn btn-primary btn-md">
+                  <button type="submit" disabled={inviting || !inviteEmail.trim()} className="btn btn-primary btn-md shrink-0">
                     {inviting ? 'Sending…' : (
                       <span className="flex items-center gap-1.5">
                         <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -392,11 +518,15 @@ export default function UsersPage() {
                       </span>
                     )}
                   </button>
-                  {inviteSuccess && (
-                    <span className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</span>
-                  )}
                 </div>
 
+                {inviteError && (
+                  <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
+                )}
+                {inviteSuccess && (
+                  <span className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</span>
+                )}
+
                 {createdLink && (
                   <div className="rounded-lg p-3 animate-scale-in"
                        style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
@@ -455,10 +585,16 @@ export default function UsersPage() {
                       <div className="flex-1 min-w-0">
                         <div className="flex items-center gap-2">
                           <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
-                          <span className="badge badge-brand text-[10px] capitalize">{inv.role.toLowerCase()}</span>
+                          {inv.type === 'WORKSPACE' ? (
+                            <span className="badge badge-danger text-[10px]">Workspace</span>
+                          ) : (
+                            <span className="badge badge-brand text-[10px]">Project</span>
+                          )}
                         </div>
                         <div className="flex items-center gap-2 mt-0.5">
-                          <span className="text-xs" style={{ color: '#818CF8' }}>{inv.project?.name}</span>
+                          <span className="text-xs" style={{ color: '#818CF8' }}>
+                            {inv.type === 'WORKSPACE' ? 'Workspace member' : (inv.project?.name ?? '—')}
+                          </span>
                           <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>·</span>
                           <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
                             Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}

+ 2 - 0
src/app/globals.css

@@ -190,6 +190,8 @@ body {
   .badge-warning{ background: var(--warning-soft); color: #FCD34D; }
   .badge-danger { background: var(--danger-soft); color: #FCA5A5; }
   .badge-muted  { background: rgba(255,255,255,0.06); color: var(--text-muted); }
+  .badge-owner  { background: rgba(252,165,165,0.12); color: #FCA5A5; }
+  .badge-subtle { background: rgba(255,255,255,0.04); color: var(--text-subtle); }
 
   /* Status pills */
   .status-pending   { background: var(--warning-soft); color: #FCD34D; }

+ 25 - 14
src/app/invite/[token]/page.tsx

@@ -178,20 +178,31 @@ export default function InvitePage() {
           </div>
         ) : !user ? (
           <div className="text-center">
-            <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
-              Create an account or sign in to accept this invitation.
-            </p>
-            <div className="space-y-2">
-              <button onClick={handleAccept} className="btn btn-primary btn-md w-full">
-                Sign in to accept
-              </button>
-              <button
-                onClick={() => router.push(`/register?invite_token=${token}`)}
-                className="btn btn-secondary btn-md w-full"
-              >
-                Create account
-              </button>
-            </div>
+            {invitation?.inviteeExists ? (
+              <>
+                <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
+                  Sign in with your account to join <strong style={{ color: '#F9FAFB' }}>{invitation?.projectName}</strong>.
+                </p>
+                <button
+                  onClick={() => router.push(`/login?invite_token=${token}`)}
+                  className="btn btn-primary btn-md w-full"
+                >
+                  Sign in &amp; Join
+                </button>
+              </>
+            ) : (
+              <>
+                <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
+                  Create an account to join <strong style={{ color: '#F9FAFB' }}>{invitation?.projectName}</strong>.
+                </p>
+                <button
+                  onClick={() => router.push(`/register?invite_token=${token}`)}
+                  className="btn btn-primary btn-md w-full"
+                >
+                  Create account &amp; Join
+                </button>
+              </>
+            )}
           </div>
         ) : invitation?.isOwnInvitation ? (
           <div className="text-center">

+ 31 - 5
src/lib/api.ts

@@ -36,7 +36,7 @@ async function apiFetch<T = unknown>(
 // ── Auth ─────────────────────────────────────────────────────────────────────
 
 export const authApi = {
-  register: (data: { email: string; name: string; password: string }) =>
+  register: (data: { email: string; name: string; password: string; inviteToken?: string }) =>
     apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', {
       method: 'POST',
       body: JSON.stringify(data),
@@ -208,6 +208,13 @@ export const usersApi = {
       token,
     }),
 
+  updateQuota: (token: string, id: string, storageQuota: number) =>
+    apiFetch<{ user: AdminUser }>(`/api/users/${id}/quota`, {
+      method: 'PUT',
+      body: JSON.stringify({ storageQuota }),
+      token,
+    }),
+
   deleteUser: (token: string, id: string) =>
     apiFetch(`/api/users/${id}`, { method: 'DELETE', token }),
 };
@@ -237,7 +244,15 @@ export const invitationsApi = {
       token,
     }),
 
-  // Admin: invite user to any project
+  // Admin: invite MEMBER to workspace (email only, no project)
+  inviteMember: (token: string, email: string) =>
+    apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations/workspace', {
+      method: 'POST',
+      body: JSON.stringify({ email }),
+      token,
+    }),
+
+  // Admin: invite PROJECT_USER to a specific project (requires projectId)
   adminInvite: (token: string, email: string, projectId: string, role: string) =>
     apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', {
       method: 'POST',
@@ -289,8 +304,12 @@ export interface User {
 export interface AdminUser extends User {
   active: boolean;
   createdAt: string;
+  storageQuota: number;   // bytes
+  storageUsed: number;    // bytes — sum of assets in owned projects
+  /** Number of projects this user owns (their storage is counted from these) */
+  ownedProjects: number;
   _count?: {
-    memberships: number;
+    memberships: number; // projects they're a member of (including owned)
     comments: number;
   };
 }
@@ -301,6 +320,8 @@ export interface Project {
   description?: string | null;
   ownerId: string;
   createdAt: string;
+  /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */
+  myRole: string | null;
   members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>;
   _count?: { assets: number };
 }
@@ -328,20 +349,25 @@ export interface AdminInvitation {
   expiresAt: string;
   createdAt: string;
   project: { id: string; name: string };
+  type?: 'WORKSPACE' | 'PROJECT';
 }
 
 export interface InvitationInfo {
   id: string;
   email: string;
   role: string;
-  projectName: string;
-  projectId: string;
+  projectName?: string | null;
+  projectId?: string | null;
+  /** 'WORKSPACE' = invite MEMBER to workspace (no project); 'PROJECT' = invite PROJECT_USER to a project */
+  type?: 'WORKSPACE' | 'PROJECT';
   expiresAt: string;
   status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
   isExpired: boolean;
   isOwnInvitation: boolean;
   alreadyMember: boolean;
   isLoggedIn: boolean;
+  /** Whether the invite email already has an account — determines sign-in vs register button */
+  inviteeExists: boolean;
 }
 
 export type TranscodeStatus =

+ 3 - 3
src/lib/auth-context.tsx

@@ -16,7 +16,7 @@ interface AuthContextValue {
   justRegisteredName: string;
   clearAcceptedProjects: () => void;
   login: (email: string, password: string) => Promise<void>;
-  register: (email: string, name: string, password: string) => Promise<void>;
+  register: (email: string, name: string, password: string, inviteToken?: string) => Promise<void>;
   logout: () => Promise<void>;
   refreshUser: () => Promise<void>;
   updateUserData: (data: Partial<User>) => void;
@@ -58,8 +58,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     setAcceptedProjects(ap ?? []);
   }, []);
 
-  const register = useCallback(async (email: string, name: string, password: string) => {
-    const result = await authApi.register({ email, name, password });
+  const register = useCallback(async (email: string, name: string, password: string, inviteToken?: string) => {
+    const result = await authApi.register({ email, name, password, inviteToken });
     const { user: u, token: t, acceptedProjects: ap, userName } = result;
     localStorage.setItem('vidreview_token', t);
     localStorage.setItem('vidreview_user', JSON.stringify(u));