From 59972418b90587d90eb912cde2965bd2f3a93c7e Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 9 Jul 2026 16:40:51 -0500 Subject: [PATCH 01/23] remove form apis --- pages/api/workspace/[id]/forms/helpers.ts | 49 --------------- pages/api/workspace/[id]/forms/index.ts | 73 ----------------------- 2 files changed, 122 deletions(-) delete mode 100644 pages/api/workspace/[id]/forms/helpers.ts delete mode 100644 pages/api/workspace/[id]/forms/index.ts diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts deleted file mode 100644 index 58cda7c6..00000000 --- a/pages/api/workspace/[id]/forms/helpers.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Orbit Forms - * Licensed under GPL-3.0 (see LICENSE for details) - * - * Helpers to make life easier in the other scripts - * - * @author BuddyWinte - * @since 2.1.10-beta20 - */ - -type Permission = string; -interface Role { - permissions: Permission[]; -} -interface UserWithRoles { - roles: Role[]; -} - -/** - * Checks if a user has a given permission. - * - * Supports: - * - Exact matches: "Form.View" - * - Wildcards: "Form.*" - * - * @returns true if the user has permissions - * @returns false if the user doesn't have permissions - * @readonly - */ -export function hasPerms( - user: UserWithRoles, - permission: string -): boolean { - if (!user?.roles?.length) return false; - for (const role of user.roles) { - if (!role?.permissions?.length) continue; - for (const perm of role.permissions) { - if (perm === permission) return true; - if (perm.endsWith(".*")) { - const prefix = perm.slice(0,-2); - if (permission.startsWith(prefix + ".")) { - return true; - } - } - } - } - - return false; -} \ No newline at end of file diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts deleted file mode 100644 index 3c21c65a..00000000 --- a/pages/api/workspace/[id]/forms/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import prisma from "@/utils/database"; -type Data = { - success: boolean; - error?: string; - forms?: unknown[]; -}; -import { withAuth, AuthenticatedRequest } from "@/lib/withAuth"; -import { hasPerms } from "./helpers"; - -export default withAuth(handler); - -// GET /api/workspace/[id]/forms - Returns a list of forms a specific user can see -// POST /api/workspace/[id]/forms - Create a new form -export async function handler( - req: AuthenticatedRequest, - res: NextApiResponse, -) { - const user = await prisma.user.findUnique({ - where: { - userid: req.auth.userId, - }, - include: { - roles: { - select: { - id: true, - name: true, - permissions: true, - }, - }, - workspaceMemberships: { - where: { - workspaceGroupId: Number(req.query.id), - }, - include: { - workspace: true, - }, - }, - }, - }); - - if (!user) { - return res.status(404).json({ - success: false, - error: "User not found", - }); - } - - if (req.method === "GET") { - if (!hasPerms(user, "Form.View")) { - return res.status(403).json({ - success: false, - error: "Missing permission: Form.View", - }); - } - - const forms = await prisma.form.findMany({ - where: { - workspaceGroupId: workspaceId, - }, - }); - - return res.status(200).json({ - success: true, - forms, - }); - } - - return res.status(405).json({ - success: false, - error: "Method Not Allowed" - }) -} From f0b72981a981e0b049e0a342f8c1b11723219190 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 9 Jul 2026 17:03:36 -0500 Subject: [PATCH 02/23] update readme --- README.md | 1 + package-lock.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba89bd99..77130a5a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Orbit ships with a comprehensive set of management tools out of the box: | **Documentation** | Host your group's docs natively inside Orbit | | **Policies** | Create and assign policy documents for members to review and sign | | **Sessions** | Schedule and host sessions with minimal overhead | +| **Forms** | Create your own forms, manage there analytics. All in one dashboard. | --- diff --git a/package-lock.json b/package-lock.json index 9ca56f1e..49a51440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "orbit", - "version": "2.1.10beta20", + "version": "2.1.10beta21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orbit", - "version": "2.1.10beta20", + "version": "2.1.10beta21", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", From 3ac48c57e7e4dbfb1c78725f8c19f591934eb34b Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 9 Jul 2026 17:04:32 -0500 Subject: [PATCH 03/23] update readme x2 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 77130a5a..1ebcebb4 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Orbit ships with a comprehensive set of management tools out of the box: | **Documentation** | Host your group's docs natively inside Orbit | | **Policies** | Create and assign policy documents for members to review and sign | | **Sessions** | Schedule and host sessions with minimal overhead | -| **Forms** | Create your own forms, manage there analytics. All in one dashboard. | +| **Forms** | Build custom forms, manage submissions, and track analytics from a single dashboard. | --- From 4bb4a1d037ad155417ea87e9b663b8d68a3e3a31 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 9 Jul 2026 17:05:33 -0500 Subject: [PATCH 04/23] re-enable the toggle --- components/settings/general/form.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/components/settings/general/form.tsx b/components/settings/general/form.tsx index b6246acf..e94cf17c 100644 --- a/components/settings/general/form.tsx +++ b/components/settings/general/form.tsx @@ -26,14 +26,8 @@ const Forms: FC = (props) => {

Create, customize, and manage workspace forms for collecting structured data, submissions, and user input across your workspace (Coming Soon)

- {/**/} From 71bdcd2319706cb993eb374e34992e6b0c85b2ba Mon Sep 17 00:00:00 2001 From: buddywinte Date: Tue, 14 Jul 2026 08:36:22 -0500 Subject: [PATCH 05/23] database done? --- prisma/schema.prisma | 212 ++++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 105 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b4d16557..748b0874 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,7 +37,7 @@ model workspace { policyLinks PolicyShareableLink[] stickyAnnouncements StickyAnnouncement[] staffResignations staffResignation[] - forms form[] + forms Form[] } model config { @@ -101,7 +101,9 @@ model user { quotaCustomCompletions QuotaCustomCompletion[] @relation("QuotaCompletionUser") quotaCompletionsReviewed QuotaCustomCompletion[] @relation("QuotaCompletionReviewer") quotaUsers QuotaUser[] - forms form[] + createdForms Form[] + formResponses FormResponse[] + formReviews FormReview[] } model AuthSession { @@ -742,120 +744,120 @@ model PolicyShareableLink { @@index([createdById]) } -model form { - id String @id @default(uuid()) @db.Uuid +model Form { + id String @id @default(uuid()) @db.Uuid workspaceGroupId Int + createdById BigInt name String description String? - isEnabled Boolean - settings Json - visibility Json? - createdById BigInt - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - createdBy user @relation(fields: [createdById], references: [userid]) - questions formQuestion[] - submissions formSubmission[] - auditLogs formAuditLog[] + slug String? + enabled Boolean @default(true) + archived Boolean @default(false) + settings Json //"allowAnonymous": false, "allowMutiple": false, "successMessage": null + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + createdBy user @relation(fields: [createdById], references: [userid]) + formPages FormPage[] + formResponses FormResponse[] + @@unique([workspaceGroupId, slug]) @@index([workspaceGroupId]) } -model formQuestion { - id String @id @default(uuid()) @db.Uuid - formId String @db.Uuid - title String - description String? - type String - required Boolean @default(false) - position Int - settings Json? - visibilityRules Json? - createdAt DateTime @default(now()) - updatedat DateTime @updatedAt - form form @relation(fields: [formId], references: [id], onDelete: Cascade) - answers formAnswer[] +model FormPage { + id String @id @default(uuid()) @db.Uuid + formId String @db.Uuid + name String + description String? + order Int + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + questions FormQuestion[] @@index([formId]) } -model formSubmission { - id String @id @default(uuid()) @db.Uuid - formId String @db.Uuid - workspaceGroupId Int - userId BigInt? - robloxUserId BigInt? - discordUserId String? - status String @default("SUBMITTED") - metadata Json? - submittedAt DateTime @default(now()) - updatedAt DateTime @updatedAt - form form @relation(fields: [formId], references: [id], onDelete: Cascade) - answers formAnswer[] - reviews formReview[] - comments formComment[] - auditLogs formAuditLog[] +enum FormQuestionType { + SHORT_TEXT + LONG_TEXT + NUMBER + BOOLEAN + DATE + TIME + DATETIME + EMAIL + URL + SINGLE_CHOICE + MULTIPLE_CHOICE + ROBLOX_USER + DISCORD_USER +} + +model FormQuestion { + id String @id @default(uuid()) @db.Uuid + pageId String @db.Uuid + label String + description String? + type FormQuestionType + required Boolean @default(false) + placeholder String? + order Int + settings Json? + createdAt DateTime @default(now()) + page FormPage @relation(fields: [pageId], references: [id], onDelete: Cascade) + answers FormAnswer[] + + @@index([pageId]) +} + +enum FormResponseStatus { + PENDING + IN_REVIEW + APPROVED + DENIED + WITHDRAWN +} + +model FormResponse { + id String @id @default(uuid()) @db.Uuid + formId String @db.Uuid + submuttedById BigInt? + status FormResponseStatus @default(PENDING) + submittedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + submittedBy user? @relation(fields: [submuttedById], references: [userid]) + answers FormAnswer[] + reviews FormReview[] @@index([formId]) - @@index([workspaceGroupId]) - @@index([status]) -} - -model formAnswer { - id String @id @default(uuid()) @db.Uuid - submissionId String @db.Uuid - questionId String @db.Uuid - value Json - createdAt DateTime @default(now()) - submission formSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - question formQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - - @@index([submissionId]) - @@index([questionId]) -} - -model formReview { - id String @id @default(uuid()) @db.Uuid - submissionId String @db.Uuid - reviewerId BigInt - vote String? - score Int? - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - submission formSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - - @@index([submissionId]) - @@index([reviewerId]) -} - -model formComment { - id String @id @default(uuid()) @db.Uuid - submissionId String @db.Uuid - authorId BigInt - content String - internal Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - submission formSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - - @@index([submissionId]) -} - -model formAuditLog { - id String @id @default(uuid()) @db.Uuid - workspaceGroupId Int? - formId String? @db.Uuid - submissionId String? @db.Uuid - actorId BigInt? - action String - details Json? - createdAt DateTime @default(now()) - form form? @relation(fields: [formId], references: [id]) - submission formSubmission? @relation(fields: [submissionId], references: [id]) + @@index([submuttedById]) +} - @@index([formId]) - @@index([submissionId]) - @@index([workspaceGroupId]) -} \ No newline at end of file +model FormAnswer { + id String @id @default(uuid()) @db.Uuid + questionId String @db.Uuid + responseId String @db.Uuid + value Json + response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) + question FormQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + + @@unique([responseId, questionId]) +} + +enum FormReviewDecision { + APPROVED + DENIED + COMMENT +} + +model FormReview { + id String @id @default(uuid()) @db.Uuid + responseId String @db.Uuid + reviewerId BigInt + decision FormReviewDecision + comment String? + createdAt DateTime @default(now()) + response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) + reviewer user @relation(fields: [reviewerId], references: [userid]) +} From ff0440c7472b2216d9ac2087029a023e9acb47f6 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Tue, 14 Jul 2026 09:35:51 -0500 Subject: [PATCH 06/23] woah a few APIs are (planned) --- pages/api/workspace/[id]/forms/README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pages/api/workspace/[id]/forms/README.md diff --git a/pages/api/workspace/[id]/forms/README.md b/pages/api/workspace/[id]/forms/README.md new file mode 100644 index 00000000..e4b9120a --- /dev/null +++ b/pages/api/workspace/[id]/forms/README.md @@ -0,0 +1,4 @@ +# Forms API +> Designed by BuddyWinte with the assistance of Pawesome Contributers + + database done? From efa8a76165bce1dfdbb29fb3c1612e3a154aee1c Mon Sep 17 00:00:00 2001 From: buddywinte Date: Tue, 14 Jul 2026 16:11:25 -0500 Subject: [PATCH 07/23] updated APIs --- pages/api/workspace/[id]/forms/README.md | 157 ++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/pages/api/workspace/[id]/forms/README.md b/pages/api/workspace/[id]/forms/README.md index e4b9120a..9b03f2b6 100644 --- a/pages/api/workspace/[id]/forms/README.md +++ b/pages/api/workspace/[id]/forms/README.md @@ -1,4 +1,159 @@ # Forms API > Designed by BuddyWinte with the assistance of Pawesome Contributers - database done? +[PR #166](https://github.com/PlanetaryOrbit/orbit/pull/166) is the RFC for the Forms. Please feel free to read there about the Forms. + +Base Url: `/api/workspace/[id]/forms`, any routes with `/` are relative to this base url. + +**Design is not final. It is very likely to change as we expand on the Forms functionality.** + +--- + +# Forms + +## Create + +`POST /` + +Creates a new form. + +### Request Body +```json +{ + "name": "Developer Application", + "description": "Apply to become a developer.", + "slug": "developer-application", + "settings": { + "allowAnonymous": false, + "allowMultiple": false + } +} +``` + +### Response +```json +{ + "success": true, + "data": { + "id": "uuid", + "name": "Developer Application", + "createdAt": "2026-07-14T00:00:00Z" + } +} +``` + +## List +`GET /` + +Returns all forms in the workspace. + +### Query Params +``` +?archived=false +?enabled=true +?page=1 +?limit=20 +?search=developer +``` + +### Response +```json +{ + "success": true, + "data": { + "forms": [ + { + "id": "uuid", + "name": "Developer Application", + "description": "...", + "enabled": true, + "archived": false, + "createdAt": "..." + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 50 + } + } +} +``` + +## Get Form +`GET /[id]` + +Returns a complete form schema. + +### Response +```json +{ + "success": true, + "data": { + "id": "uuid", + "name": "Developer Application", + "description": "...", + "settings": { + "allowAnonymous": false, + "allowMultiple": false + }, + "pages": [ + { + "id": "uuid", + "name": "General", + "questions": [] + } + ] + } +} +``` + +## Update +`PUT /[id]` + +Updates a form. +> Only send fields that need changing. + +### Request Body +```json +{ + "name": "Updated Application", + "description": "Updated description" +} +``` + +### Response +```json +{ + "success": true, + "data": { + "id": "uuid", + "updatedAt": "..." + } +} +``` + +## Delete +Deleting is not currently supported, we are still trying to plan this API. + +## Duplicate +`POST /[id]/duplicate` + +Creates a copy of a form. +> **Responses are not copied**. +> The word "**Copy**" is appended to the name of the copied form if `name` isn't given in the request body. + +### Request Body +```json +{ + "name": "Developer Application Copy" +} +``` + +### Response +```json +{ + "id": "new-uuid", + "name": "Developer Application Copy" +} +``` From 99fc9df05e6abf7d595ec414955220c7947a57f4 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 13:25:31 -0500 Subject: [PATCH 08/23] better API..still have more progreess --- pages/api/{ => public/v1}/random/music.ts | 0 pages/api/workspace/[id]/forms/README.md | 252 ++++++++++++++++++++++ pages/api/workspace/[id]/forms/helpers.ts | 30 +++ pages/api/workspace/[id]/forms/index.ts | 0 4 files changed, 282 insertions(+) rename pages/api/{ => public/v1}/random/music.ts (100%) create mode 100644 pages/api/workspace/[id]/forms/helpers.ts create mode 100644 pages/api/workspace/[id]/forms/index.ts diff --git a/pages/api/random/music.ts b/pages/api/public/v1/random/music.ts similarity index 100% rename from pages/api/random/music.ts rename to pages/api/public/v1/random/music.ts diff --git a/pages/api/workspace/[id]/forms/README.md b/pages/api/workspace/[id]/forms/README.md index 9b03f2b6..aa16ef23 100644 --- a/pages/api/workspace/[id]/forms/README.md +++ b/pages/api/workspace/[id]/forms/README.md @@ -157,3 +157,255 @@ Creates a copy of a form. "name": "Developer Application Copy" } ``` + +# Pages + +## List Pages +`GET /[id]/pages` + +### Response +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "name": "General Information", + "order": 0 + } + ] +} +``` + + +## Create +`POST /[id]/pages` + +### Request Body +```json +{ + "name": "Experience", + "order": 1 +} +``` + +### Response +```json +{ + "success": true, +} +``` + +## Update +`PATCH /[id]/pages/[pageId]` + +### Request Body +```json +{ + "name": "Updated Experience", + "order": 2 +} +``` + +## Delete +`DELETE /[id]/pages/[pageId]` + +# Questions +## List Questions +`GET /[id]/questions` + +## Create +`POST /[id]/questions` + +### Request Body +```json +{ + "label": "Why do you want to join?", + "description": "Explain your experience.", + "type": "LONG_TEXT", + "required": true, + "order": 1, + "settings": {} +} +``` + +## Update +`PATCH /[id]/questions/[questionId]` +> Only send fields that need to be updated. + +### Request Body +```json +{ + "label": "Updated question", + "required": false +} +``` + +## Delete +`DELETE /[id]/questions/[questionId]` + +## Duplicate +`POST /[id]/questions/[questionId]/duplicate` + +### Response Body +```json +{ + "success": true, + "data": { + "id": "new-question-id" + } +} +``` + +## Reorder +`PATCH /[id]/questions/reorder` +> All questions must be included in the request or a 400 BAD_REQUEST will be returned. +> Order starts at 0, and must be sequential. **Questions cannot have the same order value.** + +### Request Body +```json +[ + { + "id": "question-1", + "order": 0 + }, + { + "id": "question-2", + "order": 1 + } +] +``` + +# Responses + +## Submit +`POST /[id]/responses` + +### Request Body +```json +{ + "answers": [ + { + "questionId": "question-id", + "value": "BuddyWinte" + }, + { + "questionId": "question-id-2", + "value": true + } + ] +} +``` + +### Response Body +```json +{ + "success": true, + "data": { + "id": "response-id", + "status": "PENDING", + "submittedAt": "2026-06-01T12:00:00Z" + } +} +``` + +## List Responses +`GET /[id]/responses` + +### Query Parameters +``` +?status=PENDING +?page=1 +?limit=50 +?search=text +``` + +### Response Body +```json +{ + "success": true, + "data": { + "Responses": [ + { + "id": "response-id", + "status": "PENDING", + "submittedAt": "2026-06-01T12:00:00Z" + } + ], + "pagination": {} + } +} +``` + +## Get Response +`GET /[id]/responses/[responseId]` + +### Response Body +```json +{ + "success": true, + "data": { + "id": "uuid", + "status": "PENDING", + "submittedBy": { + "id": 123, + "username": "BuddyWinte" + }, + "answers": [ + { + "questionId": "uuid", + "value": "Example" + } + ] + } +} +``` + +## Get My Response +`GET /[id]/responses/me` +> **Authorization is required** +Returns the user's response for the form. Only returns minimal data, meaning you will need to fetch the full information through the ID for everything. + +### Response Body +```json +{ + "success": true, + "data": { + "id": "response-id", + "status": "PENDING", + "submittedAt": "2026-06-01T12:00:00Z" + } +} +``` + +## Withdraw Response +> Can only be done if a user is logged in, requires `CanWithdrawResponses` to be enabled in the forms settings. +`POST /[id]/responses/[responseId]/withdraw` + +### Response Body +```json +{ + "success": true, +} +``` + +# Reviews API coming soons + +# Miscellaneous + +## Form Statistics +`GET /[id]/stats` + +### Response Body +```json +{ + "success": true, + "data": { + "views": 500, + "responses": 120, + "pending": 20, + "approved": 80, + "denied": 20 + } +} +``` diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts new file mode 100644 index 00000000..379a1295 --- /dev/null +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -0,0 +1,30 @@ +/* + * Forms API - Build custom forms, manage submissions, and track analytics + * from a single dashboard. + * + * This module contains shared helper utilities used throughout the Forms API. + * + * @file Helpers for the Forms API. + * @module Pages/API/Workspace/[id]/Forms/Helpers + * @since 2.1.10-beta21 + * @author BuddyWinte + */ + + export interface ErrorBody { + code: string; + message: string; + details?: unknown; + } + + export type RequestResponse = + | { + success: true; + data: T; + } + | { + success: false; + error: ErrorBody; + }; + +import { withPermissionCheck } from "@/utils/permissionsManager"; +import { withAuth } from "@/lib/withAuth"; diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts new file mode 100644 index 00000000..e69de29b From 5147ca095da28b0d3855b296b1bffeb46d286fa9 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 16:09:15 -0500 Subject: [PATCH 09/23] typos fixed --- prisma/schema.prisma | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 748b0874..399652c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -773,8 +773,10 @@ model FormPage { order Int form Form @relation(fields: [formId], references: [id], onDelete: Cascade) questions FormQuestion[] - + updatedAt DateTime @updatedAt @@index([formId]) + @@unique([formId, order]) + @@unique([pageId, order]) } enum FormQuestionType { @@ -804,6 +806,7 @@ model FormQuestion { order Int settings Json? createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt page FormPage @relation(fields: [pageId], references: [id], onDelete: Cascade) answers FormAnswer[] @@ -821,12 +824,12 @@ enum FormResponseStatus { model FormResponse { id String @id @default(uuid()) @db.Uuid formId String @db.Uuid - submuttedById BigInt? + submittedById BigInt? status FormResponseStatus @default(PENDING) submittedAt DateTime @default(now()) updatedAt DateTime @updatedAt form Form @relation(fields: [formId], references: [id], onDelete: Cascade) - submittedBy user? @relation(fields: [submuttedById], references: [userid]) + submittedBy user? @relation(fields: [submittedById], references: [userid]) answers FormAnswer[] reviews FormReview[] From b4aab98c122c4975bc3bcf965208d711596d1613 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 16:14:24 -0500 Subject: [PATCH 10/23] Added permission overides to DB --- prisma/schema.prisma | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 399652c5..5586eabb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -864,3 +864,21 @@ model FormReview { response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) reviewer user @relation(fields: [reviewerId], references: [userid]) } + +model FormPermission { + id String @id @default(uuid()) @db.Uuid + formId String @db.Uuid + roleId String? @db.Uuid + userId BigInt? + allow String[] + deny String[] + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + role Role? @relation(fields: [roleId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@index([formId]) + @@index([roleId]) + @@index([userId]) + @@unique([formId, roleId]) + @@unique([formId, userId]) +} From 99e76afc7a7a1d6147a362bbd3aa91d3875147cd Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 16:17:53 -0500 Subject: [PATCH 11/23] fixed a few typos --- prisma/schema.prisma | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5586eabb..7aa4de81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -758,8 +758,9 @@ model Form { updatedAt DateTime @updatedAt workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) createdBy user @relation(fields: [createdById], references: [userid]) - formPages FormPage[] - formResponses FormResponse[] + pages FormPage[] + responses FormResponse[] + permissions FormPermission[] @@unique([workspaceGroupId, slug]) @@index([workspaceGroupId]) @@ -776,7 +777,6 @@ model FormPage { updatedAt DateTime @updatedAt @@index([formId]) @@unique([formId, order]) - @@unique([pageId, order]) } enum FormQuestionType { @@ -811,6 +811,7 @@ model FormQuestion { answers FormAnswer[] @@index([pageId]) + @@unique([pageId, order]) } enum FormResponseStatus { @@ -834,7 +835,7 @@ model FormResponse { reviews FormReview[] @@index([formId]) - @@index([submuttedById]) + @@index([submittedById]) } model FormAnswer { @@ -861,6 +862,7 @@ model FormReview { decision FormReviewDecision comment String? createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) reviewer user @relation(fields: [reviewerId], references: [userid]) } @@ -873,8 +875,8 @@ model FormPermission { allow String[] deny String[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) - role Role? @relation(fields: [roleId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [userId], onDelete: Cascade) + role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) + user user? @relation(fields: [userId], references: [userId], onDelete: Cascade) @@index([formId]) @@index([roleId]) From 56386339125b7ba19ed1cb337156c12d9653707a Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 16:30:09 -0500 Subject: [PATCH 12/23] added formPermission --- prisma/schema.prisma | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7aa4de81..f24ffa71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -758,9 +758,9 @@ model Form { updatedAt DateTime @updatedAt workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) createdBy user @relation(fields: [createdById], references: [userid]) - pages FormPage[] + pages FormPage[] responses FormResponse[] - permissions FormPermission[] + permissions FormPermission[] @@unique([workspaceGroupId, slug]) @@index([workspaceGroupId]) @@ -867,13 +867,33 @@ model FormReview { reviewer user @relation(fields: [reviewerId], references: [userid]) } +enum FormPermissionType { + ManagePages + ManageQuestions + ManageQuestionSettings + ManageSettings + ManagePermissions + ViewResponses + ViewOwnResponses + SubmitResponses + WithdrawResponses + DeleteResponses + ReviewResponses + ApproveResponses + DenyResponses + AddReviewComments + ManageReviews + ExportResponses + ViewStatistics +} + model FormPermission { id String @id @default(uuid()) @db.Uuid formId String @db.Uuid roleId String? @db.Uuid userId BigInt? - allow String[] - deny String[] + allow FormPermissionType[] + deny FormPermissionType[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) user user? @relation(fields: [userId], references: [userId], onDelete: Cascade) From beb3e5f63882ca591de88667592f8a65572f96df Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:16:02 -0500 Subject: [PATCH 13/23] hasPermission function added to helper --- pages/api/workspace/[id]/forms/helpers.ts | 74 ++++++++++++++++++----- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts index 379a1295..a53452f7 100644 --- a/pages/api/workspace/[id]/forms/helpers.ts +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -9,22 +9,64 @@ * @since 2.1.10-beta21 * @author BuddyWinte */ +import { prisma } from "@/lib/prisma"; - export interface ErrorBody { - code: string; - message: string; - details?: unknown; - } +export interface ErrorBody { + code: string; + message: string; + details?: unknown; +} - export type RequestResponse = - | { - success: true; - data: T; - } - | { - success: false; - error: ErrorBody; - }; +export type RequestResponse = + | { + success: true; + data: T; + } + | { + success: false; + error: ErrorBody; + }; -import { withPermissionCheck } from "@/utils/permissionsManager"; -import { withAuth } from "@/lib/withAuth"; +export enum FormPermissionType { + ManagePages = "ManagePages", + ManageQuestions = "ManageQuestions", + ManageQuestionSettings = "ManageQuestionSettings", + ManageSettings = "ManageSettings", + ManagePermissions = "ManagePermissions", + ViewResponses = "ViewResponses", + ViewOwnResponses = "ViewOwnResponses", + SubmitResponses = "SubmitResponses", + WithdrawResponses = "WithdrawResponses", + DeleteResponses = "DeleteResponses", + ReviewResponses = "ReviewResponses", + ApproveResponses = "ApproveResponses", + DenyResponses = "DenyResponses", + AddReviewComments = "AddReviewComments", + ManageReviews = "ManageReviews", + ExportResponses = "ExportResponses", + ViewStatistics = "ViewStatistics", +} + +type PermissionSet = { + allow: FormPermissionType[]; + deny: FormPermissionType[]; +}; + +type PermissionContext = { + isOwner: boolean; + + global: PermissionSet; + form: PermissionSet; +}; + +export function hasFormPermission( + ctx: PermissionContext, + permission: FormPermissionType, +): boolean { + if (ctx.isOwner) return true; + if (ctx.global.deny.includes(permission)) return false; + if (ctx.form.deny.includes(permission)) return false; + if (ctx.form.allow.includes(permission)) return true; + if (ctx.global.allow.includes(permission)) return true; + return false; +} From b95152e15d0e548f963e51130c195b0ad58c433d Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:18:58 -0500 Subject: [PATCH 14/23] its supposed to be uppercased dumbass --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f24ffa71..75ddb1ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -896,7 +896,7 @@ model FormPermission { deny FormPermissionType[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) - user user? @relation(fields: [userId], references: [userId], onDelete: Cascade) + user User? @relation(fields: [userId], references: [userId], onDelete: Cascade) @@index([formId]) @@index([roleId]) From 8d117894d45d73a97fcb4e2d4a507b056004c400 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:21:40 -0500 Subject: [PATCH 15/23] im a dumbass --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75ddb1ad..bf6e47e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -896,7 +896,7 @@ model FormPermission { deny FormPermissionType[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [userId], onDelete: Cascade) + user user? @relation(fields: [userId], references: [userid], onDelete: Cascade) @@index([formId]) @@index([roleId]) From c903f5e732f9fb2f70a1acb49162aa4158260b16 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:23:33 -0500 Subject: [PATCH 16/23] might be helpful to add those to the models --- prisma/schema.prisma | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf6e47e4..0f13e726 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,6 +104,7 @@ model user { createdForms Form[] formResponses FormResponse[] formReviews FormReview[] + formPermissions FormPermission[] } model AuthSession { @@ -215,6 +216,7 @@ model role { documents document[] @relation("documentTorole") members user[] @relation("roleTouser") roleMembers RoleMember[] + formPermissions FormPermission[] } model RoleMember { @@ -896,7 +898,7 @@ model FormPermission { deny FormPermissionType[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) - user user? @relation(fields: [userId], references: [userid], onDelete: Cascade) + user user? @relation(fields: [userId], references: [userId], onDelete: Cascade) @@index([formId]) @@index([roleId]) From 2a46ffa4c2c94c6d3bb637623b56c8574f0f0bee Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:25:35 -0500 Subject: [PATCH 17/23] GOD DAMIT ITS LOWERCASE --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f13e726..bb7ab06d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -898,7 +898,7 @@ model FormPermission { deny FormPermissionType[] form Form @relation(fields: [formId], references: [id], onDelete: Cascade) role role? @relation(fields: [roleId], references: [id], onDelete: Cascade) - user user? @relation(fields: [userId], references: [userId], onDelete: Cascade) + user user? @relation(fields: [userId], references: [userid], onDelete: Cascade) @@index([formId]) @@index([roleId]) From 9c361b7befe0c90476313fd5f02982255451ca7a Mon Sep 17 00:00:00 2001 From: buddywinte Date: Wed, 15 Jul 2026 19:53:06 -0500 Subject: [PATCH 18/23] GET / works --- pages/api/workspace/[id]/forms/helpers.ts | 155 +++++++++++++++++++--- pages/api/workspace/[id]/forms/index.ts | 75 +++++++++++ 2 files changed, 212 insertions(+), 18 deletions(-) diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts index a53452f7..6a718fa7 100644 --- a/pages/api/workspace/[id]/forms/helpers.ts +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -28,25 +28,53 @@ export type RequestResponse = }; export enum FormPermissionType { - ManagePages = "ManagePages", - ManageQuestions = "ManageQuestions", - ManageQuestionSettings = "ManageQuestionSettings", - ManageSettings = "ManageSettings", - ManagePermissions = "ManagePermissions", - ViewResponses = "ViewResponses", - ViewOwnResponses = "ViewOwnResponses", - SubmitResponses = "SubmitResponses", - WithdrawResponses = "WithdrawResponses", - DeleteResponses = "DeleteResponses", - ReviewResponses = "ReviewResponses", - ApproveResponses = "ApproveResponses", - DenyResponses = "DenyResponses", - AddReviewComments = "AddReviewComments", - ManageReviews = "ManageReviews", - ExportResponses = "ExportResponses", - ViewStatistics = "ViewStatistics", + // Global + Create = "Forms.Create", + View = "Forms.View", + Edit = "Forms.Edit", + Delete = "Forms.Delete", + Duplicate = "Forms.Duplicate", + + // Overrideable + ManagePages = "Forms.ManagePages", + ManageQuestions = "Forms.ManageQuestions", + ManageQuestionSettings = "Forms.ManageQuestionSettings", + ManageSettings = "Forms.ManageSettings", + ManagePermissions = "Forms.ManagePermissions", + ViewResponses = "Forms.ViewResponses", + ViewOwnResponses = "Forms.ViewOwnResponses", + SubmitResponses = "Forms.SubmitResponses", + WithdrawResponses = "Forms.WithdrawResponses", + DeleteResponses = "Forms.DeleteResponses", + ReviewResponses = "Forms.ReviewResponses", + ApproveResponses = "Forms.ApproveResponses", + DenyResponses = "Forms.DenyResponses", + AddReviewComments = "Forms.AddReviewComments", + ManageReviews = "Forms.ManageReviews", + ExportResponses = "Forms.ExportResponses", + ViewStatistics = "Forms.ViewStatistics", } +const OverrideableFormPermissions = [ + FormPermissionType.ManagePages, + FormPermissionType.ManageQuestions, + FormPermissionType.ManageQuestionSettings, + FormPermissionType.ManageSettings, + FormPermissionType.ManagePermissions, + FormPermissionType.ViewResponses, + FormPermissionType.ViewOwnResponses, + FormPermissionType.SubmitResponses, + FormPermissionType.WithdrawResponses, + FormPermissionType.DeleteResponses, + FormPermissionType.ReviewResponses, + FormPermissionType.ApproveResponses, + FormPermissionType.DenyResponses, + FormPermissionType.AddReviewComments, + FormPermissionType.ManageReviews, + FormPermissionType.ExportResponses, + FormPermissionType.ViewStatistics, +]; + type PermissionSet = { allow: FormPermissionType[]; deny: FormPermissionType[]; @@ -64,9 +92,100 @@ export function hasFormPermission( permission: FormPermissionType, ): boolean { if (ctx.isOwner) return true; - if (ctx.global.deny.includes(permission)) return false; + + // Form if (ctx.form.deny.includes(permission)) return false; if (ctx.form.allow.includes(permission)) return true; + + // Global + if (ctx.global.deny.includes(permission)) return false; if (ctx.global.allow.includes(permission)) return true; + return false; } + +export async function getFormPermissions({ + formId, + userId, + isOwner, + roleIds, +}: { + formId: string; + userId: bigint; + isOwner: boolean; + roleIds: string[]; +}): Promise { + if (isOwner) { + return { + isOwner: true, + global: { + allow: Object.values(FormPermissionType), + deny: [], + }, + form: { + allow: Object.values(FormPermissionType), + deny: [], + }, + }; + } + + const roles = await prisma.role.findMany({ + where: { + id: { + in: roleIds, + }, + }, + select: { + permissions: true, + }, + }); + + const global: PermissionSet = { + allow: [], + deny: [], + }; + + for (const role of roles) { + for (const permission of role.permissions) { + if ( + Object.values(FormPermissionType).includes( + permission as FormPermissionType, + ) + ) { + global.allow.push(permission as FormPermissionType); + } + } + } + + const overrides = await prisma.formPermission.findMany({ + where: { + formId, + OR: [ + { + userId, + }, + { + roleId: { + in: roleIds, + }, + }, + ], + }, + }); + + const form: PermissionSet = { + allow: [], + deny: [], + }; + + for (const override of overrides) { + form.allow.push(...override.allow); + form.deny.push(...override.deny); + } + + return { + isOwner, + global, + form, + }; +} diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts index e69de29b..a17928cf 100644 --- a/pages/api/workspace/[id]/forms/index.ts +++ b/pages/api/workspace/[id]/forms/index.ts @@ -0,0 +1,75 @@ +/* + * Forms API - Build custom forms, manage submissions, and track analytics + * from a single dashboard. + * + * This file manages `POST /` and `GET /` requests for the Forms API. + * + * @file API route handler for the Forms API. + * @module Pages/API/Workspace/[id]/Forms + * @since 2.1.10-beta21 + * @author BuddyWinte + */ +import { FormPermissionType, getFormPermissions, hasFormPermission } from "./helpers"; +import { withAuth } from "@/lib/withAuth"; +import { prisma } from "@/lib/prisma"; + +export default withAuth(async (req, res) => { + try { + const { auth } = req; + const { session } = auth; + const { userid } = session; + const { id } = req.query; + const { method } = req; + + if (method === "GET") { + const forms = await prisma.form.findMany({ + where: { + workspaceGroupId: Number(id), + }, + }); + + const filtered = await Promise.all( + forms.map(async (form) => { + const permissions = await getFormPermissions({ + formId: form.id, + userId: userid, + isOwner: session.isOwner ?? true, + roleIds: session.roles ?? [], + }); + + if ( + hasFormPermission(permissions, FormPermissionType.View)) { + return form + } + + return null; + }), + ); + + const visible = filtered.filter( + (form): form is typeof forms[number] => form !== null + ); + + return res.status(200).json({ + success: true, + data: visible + }); + } + + return res.status(405).json({ + success: false, + error: { + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }, + }); + } catch (err) { + return res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Internal server error" + }, + }); + } +}); From 87088b438a9289a56adffbcf5919a50b34d82ed9 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 30 Jul 2026 20:54:58 -0500 Subject: [PATCH 19/23] use strict --- pages/api/workspace/[id]/forms/helpers.ts | 2 ++ pages/api/workspace/[id]/forms/index.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts index 6a718fa7..bf28a558 100644 --- a/pages/api/workspace/[id]/forms/helpers.ts +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -9,6 +9,8 @@ * @since 2.1.10-beta21 * @author BuddyWinte */ +"use strict"; + import { prisma } from "@/lib/prisma"; export interface ErrorBody { diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts index a17928cf..e01a1247 100644 --- a/pages/api/workspace/[id]/forms/index.ts +++ b/pages/api/workspace/[id]/forms/index.ts @@ -9,6 +9,8 @@ * @since 2.1.10-beta21 * @author BuddyWinte */ +"use strict"; + import { FormPermissionType, getFormPermissions, hasFormPermission } from "./helpers"; import { withAuth } from "@/lib/withAuth"; import { prisma } from "@/lib/prisma"; From 33b190a1924992e6561122de35838d0132aa14d8 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 30 Jul 2026 20:58:01 -0500 Subject: [PATCH 20/23] optimizations --- pages/api/workspace/[id]/forms/index.ts | 154 +++++++++++++++++++----- 1 file changed, 122 insertions(+), 32 deletions(-) diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts index e01a1247..aa090fcd 100644 --- a/pages/api/workspace/[id]/forms/index.ts +++ b/pages/api/workspace/[id]/forms/index.ts @@ -20,57 +20,147 @@ export default withAuth(async (req, res) => { const { auth } = req; const { session } = auth; const { userid } = session; - const { id } = req.query; - const { method } = req; - if (method === "GET") { - const forms = await prisma.form.findMany({ - where: { - workspaceGroupId: Number(id), + const workspaceId = Number(req.query.id); + + if (req.method !== "GET") { + return res.status(405).json({ + success: false, + error: { + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed", }, }); + } - const filtered = await Promise.all( - forms.map(async (form) => { - const permissions = await getFormPermissions({ - formId: form.id, - userId: userid, - isOwner: session.isOwner ?? true, - roleIds: session.roles ?? [], - }); + const { + archived, + enabled, + search, + page = "1", + limit = "20", + } = req.query; + const pageNumber = Math.max(Number(page), 1); + const limitNumber = Math.min(Math.max(Number(limit), 1), 100); + const where = { + workspaceGroupId: workspaceId, + ...(archived !== undefined && { + archived: archived === "true", + }), + ...(enabled !== undefined && { + enabled: enabled === "true", + }), + ...(typeof search === "string" && + search.length > 0 && { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + description: { + contains: search, + mode: "insensitive", + }, + }, + ], + }), + }; - if ( - hasFormPermission(permissions, FormPermissionType.View)) { - return form - } + const [forms, total] = await Promise.all([ + prisma.form.findMany({ + where, + select: { + id: true, + name: true, + description: true, + enabled: true, + archived: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: "desc", + }, + skip: (pageNumber - 1) * limitNumber, + take: limitNumber, + }), - return null; - }), - ); + prisma.form.count({ + where, + }), + ]); - const visible = filtered.filter( - (form): form is typeof forms[number] => form !== null - ); + if (!session.isOwner) { + const permissions = await prisma.formPermission.findMany({ + where: { + formId: { + in: forms.map((form) => form.id), + }, + OR: [ + { + userId: userid, + }, + { + roleId: { + in: session.roles ?? [], + }, + }, + ], + }, + }); + const visible = forms.filter((form) => { + const overrides = permissions.filter( + (permission) => permission.formId === form.id, + ); + return hasFormPermission( + { + isOwner: false, + global: { + allow: [], + deny: [], + }, + form: { + allow: overrides.flatMap((x) => x.allow), + deny: overrides.flatMap((x) => x.deny), + }, + }, + FormPermissionType.View, + ); + }); return res.status(200).json({ success: true, - data: visible + data: { + forms: visible, + pagination: { + page: pageNumber, + limit: limitNumber, + total, + }, + }, }); } - - return res.status(405).json({ - success: false, - error: { - code: "METHOD_NOT_ALLOWED", - message: "Method not allowed" + return res.status(200).json({ + success: true, + data: { + forms, + pagination: { + page: pageNumber, + limit: limitNumber, + total, + }, }, }); } catch (err) { + console.error("[Forms] Failed to list forms:", err); return res.status(500).json({ success: false, error: { code: "INTERNAL_SERVER_ERROR", - message: "Internal server error" + message: "Internal server error", }, }); } From b1d7cf955fadff5943d228d3ab220f773adc7e70 Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 30 Jul 2026 21:02:44 -0500 Subject: [PATCH 21/23] faster --- pages/api/workspace/[id]/forms/index.ts | 136 ++++++++++++++++-------- prisma/schema.prisma | 3 + 2 files changed, 94 insertions(+), 45 deletions(-) diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts index aa090fcd..8d6d21f9 100644 --- a/pages/api/workspace/[id]/forms/index.ts +++ b/pages/api/workspace/[id]/forms/index.ts @@ -11,18 +11,12 @@ */ "use strict"; -import { FormPermissionType, getFormPermissions, hasFormPermission } from "./helpers"; import { withAuth } from "@/lib/withAuth"; import { prisma } from "@/lib/prisma"; +import { FormPermissionType } from "./helpers"; export default withAuth(async (req, res) => { try { - const { auth } = req; - const { session } = auth; - const { userid } = session; - - const workspaceId = Number(req.query.id); - if (req.method !== "GET") { return res.status(405).json({ success: false, @@ -33,6 +27,9 @@ export default withAuth(async (req, res) => { }); } + const { session } = req.auth; + const workspaceId = Number(req.query.id); + const { archived, enabled, @@ -40,33 +37,37 @@ export default withAuth(async (req, res) => { page = "1", limit = "20", } = req.query; + const pageNumber = Math.max(Number(page), 1); const limitNumber = Math.min(Math.max(Number(limit), 1), 100); + const where = { workspaceGroupId: workspaceId, + ...(archived !== undefined && { archived: archived === "true", }), + ...(enabled !== undefined && { enabled: enabled === "true", }), - ...(typeof search === "string" && - search.length > 0 && { - OR: [ - { - name: { - contains: search, - mode: "insensitive", - }, + + ...(typeof search === "string" && { + OR: [ + { + name: { + contains: search, + mode: "insensitive", }, - { - description: { - contains: search, - mode: "insensitive", - }, + }, + { + description: { + contains: search, + mode: "insensitive", }, - ], - }), + }, + ], + }), }; const [forms, total] = await Promise.all([ @@ -76,6 +77,7 @@ export default withAuth(async (req, res) => { id: true, name: true, description: true, + slug: true, enabled: true, archived: true, createdAt: true, @@ -93,15 +95,43 @@ export default withAuth(async (req, res) => { }), ]); - if (!session.isOwner) { - const permissions = await prisma.formPermission.findMany({ + if (!session.isOwner && forms.length > 0) { + const roles = await prisma.role.findMany({ + where: { + id: { + in: session.roles ?? [], + }, + }, + select: { + permissions: true, + }, + }); + + + const globalPermissions = new Set(); + + for (const role of roles) { + for (const permission of role.permissions) { + if ( + Object.values(FormPermissionType) + .includes(permission as FormPermissionType) + ) { + globalPermissions.add( + permission as FormPermissionType + ); + } + } + } + + + const overrides = await prisma.formPermission.findMany({ where: { formId: { - in: forms.map((form) => form.id), + in: forms.map(x => x.id), }, OR: [ { - userId: userid, + userId: session.userid, }, { roleId: { @@ -112,29 +142,41 @@ export default withAuth(async (req, res) => { }, }); - const visible = forms.filter((form) => { - const overrides = permissions.filter( - (permission) => permission.formId === form.id, - ); - return hasFormPermission( - { - isOwner: false, - global: { - allow: [], - deny: [], - }, - form: { - allow: overrides.flatMap((x) => x.allow), - deny: overrides.flatMap((x) => x.deny), - }, - }, - FormPermissionType.View, + + const visibleForms = forms.filter(form => { + const formPermissions = + overrides.filter( + x => x.formId === form.id + ); + + + const denied = new Set(); + const allowed = new Set(); + + for (const permission of formPermissions) { + permission.deny.forEach(x => denied.add(x)); + permission.allow.forEach(x => allowed.add(x)); + } + + + if (denied.has(FormPermissionType.View)) { + return false; + } + + if (allowed.has(FormPermissionType.View)) { + return true; + } + + return globalPermissions.has( + FormPermissionType.View ); }); + + return res.status(200).json({ success: true, data: { - forms: visible, + forms: visibleForms, pagination: { page: pageNumber, limit: limitNumber, @@ -143,6 +185,8 @@ export default withAuth(async (req, res) => { }, }); } + + return res.status(200).json({ success: true, data: { @@ -154,8 +198,10 @@ export default withAuth(async (req, res) => { }, }, }); + } catch (err) { - console.error("[Forms] Failed to list forms:", err); + console.error("[Forms] Failed to list forms", err); + return res.status(500).json({ success: false, error: { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb7ab06d..acbdfc02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -766,6 +766,8 @@ model Form { @@unique([workspaceGroupId, slug]) @@index([workspaceGroupId]) + @@index([workspaceGroupId, archived, enabled]) + @@index([workspaceGroupId, createdAt]) } model FormPage { @@ -905,4 +907,5 @@ model FormPermission { @@index([userId]) @@unique([formId, roleId]) @@unique([formId, userId]) + @@unique([formId, roleId, userId]) } From 71495ba9d1c35290b49cf36894b1e397bafb124c Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 30 Jul 2026 21:12:08 -0500 Subject: [PATCH 22/23] its like 2am im running on like no sleep what the fuck is going on --- pages/api/workspace/[id]/forms/helpers.ts | 66 +++----------- pages/api/workspace/[id]/forms/index.ts | 106 ++++++++++++++-------- 2 files changed, 77 insertions(+), 95 deletions(-) diff --git a/pages/api/workspace/[id]/forms/helpers.ts b/pages/api/workspace/[id]/forms/helpers.ts index bf28a558..dbcf429a 100644 --- a/pages/api/workspace/[id]/forms/helpers.ts +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -2,16 +2,19 @@ * Forms API - Build custom forms, manage submissions, and track analytics * from a single dashboard. * - * This module contains shared helper utilities used throughout the Forms API. + * This file contains helpers for the Forms API. * - * @file Helpers for the Forms API. - * @module Pages/API/Workspace/[id]/Forms/Helpers + * @module Pages/API/Workspace/[id]/Forms * @since 2.1.10-beta21 * @author BuddyWinte */ + "use strict"; import { prisma } from "@/lib/prisma"; +import { FormPermissionType } from "@prisma/client"; + +export { FormPermissionType }; export interface ErrorBody { code: string; @@ -29,54 +32,6 @@ export type RequestResponse = error: ErrorBody; }; -export enum FormPermissionType { - // Global - Create = "Forms.Create", - View = "Forms.View", - Edit = "Forms.Edit", - Delete = "Forms.Delete", - Duplicate = "Forms.Duplicate", - - // Overrideable - ManagePages = "Forms.ManagePages", - ManageQuestions = "Forms.ManageQuestions", - ManageQuestionSettings = "Forms.ManageQuestionSettings", - ManageSettings = "Forms.ManageSettings", - ManagePermissions = "Forms.ManagePermissions", - ViewResponses = "Forms.ViewResponses", - ViewOwnResponses = "Forms.ViewOwnResponses", - SubmitResponses = "Forms.SubmitResponses", - WithdrawResponses = "Forms.WithdrawResponses", - DeleteResponses = "Forms.DeleteResponses", - ReviewResponses = "Forms.ReviewResponses", - ApproveResponses = "Forms.ApproveResponses", - DenyResponses = "Forms.DenyResponses", - AddReviewComments = "Forms.AddReviewComments", - ManageReviews = "Forms.ManageReviews", - ExportResponses = "Forms.ExportResponses", - ViewStatistics = "Forms.ViewStatistics", -} - -const OverrideableFormPermissions = [ - FormPermissionType.ManagePages, - FormPermissionType.ManageQuestions, - FormPermissionType.ManageQuestionSettings, - FormPermissionType.ManageSettings, - FormPermissionType.ManagePermissions, - FormPermissionType.ViewResponses, - FormPermissionType.ViewOwnResponses, - FormPermissionType.SubmitResponses, - FormPermissionType.WithdrawResponses, - FormPermissionType.DeleteResponses, - FormPermissionType.ReviewResponses, - FormPermissionType.ApproveResponses, - FormPermissionType.DenyResponses, - FormPermissionType.AddReviewComments, - FormPermissionType.ManageReviews, - FormPermissionType.ExportResponses, - FormPermissionType.ViewStatistics, -]; - type PermissionSet = { allow: FormPermissionType[]; deny: FormPermissionType[]; @@ -84,7 +39,6 @@ type PermissionSet = { type PermissionContext = { isOwner: boolean; - global: PermissionSet; form: PermissionSet; }; @@ -95,11 +49,11 @@ export function hasFormPermission( ): boolean { if (ctx.isOwner) return true; - // Form + // Form overrides take priority if (ctx.form.deny.includes(permission)) return false; if (ctx.form.allow.includes(permission)) return true; - // Global + // Global permissions if (ctx.global.deny.includes(permission)) return false; if (ctx.global.allow.includes(permission)) return true; @@ -154,7 +108,9 @@ export async function getFormPermissions({ permission as FormPermissionType, ) ) { - global.allow.push(permission as FormPermissionType); + global.allow.push( + permission as FormPermissionType, + ); } } } diff --git a/pages/api/workspace/[id]/forms/index.ts b/pages/api/workspace/[id]/forms/index.ts index 8d6d21f9..f6a550fb 100644 --- a/pages/api/workspace/[id]/forms/index.ts +++ b/pages/api/workspace/[id]/forms/index.ts @@ -9,8 +9,10 @@ * @since 2.1.10-beta21 * @author BuddyWinte */ + "use strict"; +import { Prisma } from "@prisma/client"; import { withAuth } from "@/lib/withAuth"; import { prisma } from "@/lib/prisma"; import { FormPermissionType } from "./helpers"; @@ -28,6 +30,17 @@ export default withAuth(async (req, res) => { } const { session } = req.auth; + + if (!session) { + return res.status(401).json({ + success: false, + error: { + code: "UNAUTHORIZED", + message: "Unauthorized", + }, + }); + } + const workspaceId = Number(req.query.id); const { @@ -41,7 +54,7 @@ export default withAuth(async (req, res) => { const pageNumber = Math.max(Number(page), 1); const limitNumber = Math.min(Math.max(Number(limit), 1), 100); - const where = { + const where: Prisma.FormWhereInput = { workspaceGroupId: workspaceId, ...(archived !== undefined && { @@ -52,22 +65,23 @@ export default withAuth(async (req, res) => { enabled: enabled === "true", }), - ...(typeof search === "string" && { - OR: [ - { - name: { - contains: search, - mode: "insensitive", + ...(typeof search === "string" && + search.length > 0 && { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, }, - }, - { - description: { - contains: search, - mode: "insensitive", + { + description: { + contains: search, + mode: "insensitive", + }, }, - }, - ], - }), + ], + }), }; const [forms, total] = await Promise.all([ @@ -95,11 +109,26 @@ export default withAuth(async (req, res) => { }), ]); - if (!session.isOwner && forms.length > 0) { + const isOwner = session.user.isOwner ?? false; + + if (!isOwner && forms.length > 0) { + const roleMemberships = await prisma.roleMember.findMany({ + where: { + userId: session.user.userid, + }, + select: { + roleId: true, + }, + }); + + const roleIds = roleMemberships.map( + role => role.roleId, + ); + const roles = await prisma.role.findMany({ where: { id: { - in: session.roles ?? [], + in: roleIds, }, }, select: { @@ -107,57 +136,57 @@ export default withAuth(async (req, res) => { }, }); - const globalPermissions = new Set(); for (const role of roles) { for (const permission of role.permissions) { if ( - Object.values(FormPermissionType) - .includes(permission as FormPermissionType) + Object.values(FormPermissionType).includes( + permission as FormPermissionType, + ) ) { globalPermissions.add( - permission as FormPermissionType + permission as FormPermissionType, ); } } } - const overrides = await prisma.formPermission.findMany({ where: { formId: { - in: forms.map(x => x.id), + in: forms.map(form => form.id), }, OR: [ { - userId: session.userid, + userId: session.user.userid, }, { roleId: { - in: session.roles ?? [], + in: roleIds, }, }, ], }, }); - const visibleForms = forms.filter(form => { - const formPermissions = - overrides.filter( - x => x.formId === form.id - ); - + const permissions = overrides.filter( + override => override.formId === form.id, + ); - const denied = new Set(); const allowed = new Set(); + const denied = new Set(); - for (const permission of formPermissions) { - permission.deny.forEach(x => denied.add(x)); - permission.allow.forEach(x => allowed.add(x)); - } + for (const permission of permissions) { + permission.allow.forEach(value => + allowed.add(value), + ); + permission.deny.forEach(value => + denied.add(value), + ); + } if (denied.has(FormPermissionType.View)) { return false; @@ -168,11 +197,10 @@ export default withAuth(async (req, res) => { } return globalPermissions.has( - FormPermissionType.View + FormPermissionType.View, ); }); - return res.status(200).json({ success: true, data: { @@ -186,7 +214,6 @@ export default withAuth(async (req, res) => { }); } - return res.status(200).json({ success: true, data: { @@ -198,7 +225,6 @@ export default withAuth(async (req, res) => { }, }, }); - } catch (err) { console.error("[Forms] Failed to list forms", err); From e4db48ee5201e5475eb46771ade8fff796b64d5d Mon Sep 17 00:00:00 2001 From: buddywinte Date: Thu, 30 Jul 2026 21:17:12 -0500 Subject: [PATCH 23/23] FUCKKK --- prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index acbdfc02..2d5181ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -872,6 +872,7 @@ model FormReview { } enum FormPermissionType { + View ManagePages ManageQuestions ManageQuestionSettings