diff --git a/README.md b/README.md index ba89bd99..1ebcebb4 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** | Build custom forms, manage submissions, and track analytics from a single dashboard. | --- 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)

- {/**/} 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", 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 new file mode 100644 index 00000000..aa16ef23 --- /dev/null +++ b/pages/api/workspace/[id]/forms/README.md @@ -0,0 +1,411 @@ +# Forms API +> Designed by BuddyWinte with the assistance of Pawesome Contributers + +[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" +} +``` + +# 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..dbcf429a --- /dev/null +++ b/pages/api/workspace/[id]/forms/helpers.ts @@ -0,0 +1,149 @@ +/* + * Forms API - Build custom forms, manage submissions, and track analytics + * from a single dashboard. + * + * This file contains helpers for the Forms API. + * + * @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; + message: string; + details?: unknown; +} + +export type RequestResponse = + | { + success: true; + data: T; + } + | { + success: false; + error: ErrorBody; + }; + +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; + + // Form overrides take priority + if (ctx.form.deny.includes(permission)) return false; + if (ctx.form.allow.includes(permission)) return true; + + // Global permissions + 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 new file mode 100644 index 00000000..f6a550fb --- /dev/null +++ b/pages/api/workspace/[id]/forms/index.ts @@ -0,0 +1,239 @@ +/* + * 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 + */ + +"use strict"; + +import { Prisma } from "@prisma/client"; +import { withAuth } from "@/lib/withAuth"; +import { prisma } from "@/lib/prisma"; +import { FormPermissionType } from "./helpers"; + +export default withAuth(async (req, res) => { + try { + if (req.method !== "GET") { + return res.status(405).json({ + success: false, + error: { + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed", + }, + }); + } + + 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 { + 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: Prisma.FormWhereInput = { + 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", + }, + }, + ], + }), + }; + + const [forms, total] = await Promise.all([ + prisma.form.findMany({ + where, + select: { + id: true, + name: true, + description: true, + slug: true, + enabled: true, + archived: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: "desc", + }, + skip: (pageNumber - 1) * limitNumber, + take: limitNumber, + }), + + prisma.form.count({ + where, + }), + ]); + + 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: roleIds, + }, + }, + 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), + }, + OR: [ + { + userId: session.user.userid, + }, + { + roleId: { + in: roleIds, + }, + }, + ], + }, + }); + + const visibleForms = forms.filter(form => { + const permissions = overrides.filter( + override => override.formId === form.id, + ); + + const allowed = new Set(); + const denied = new Set(); + + 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; + } + + if (allowed.has(FormPermissionType.View)) { + return true; + } + + return globalPermissions.has( + FormPermissionType.View, + ); + }); + + return res.status(200).json({ + success: true, + data: { + forms: visibleForms, + pagination: { + page: pageNumber, + limit: limitNumber, + total, + }, + }, + }); + } + + 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", + }, + }); + } +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b4d16557..2d5181ac 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,10 @@ model user { quotaCustomCompletions QuotaCustomCompletion[] @relation("QuotaCompletionUser") quotaCompletionsReviewed QuotaCustomCompletion[] @relation("QuotaCompletionReviewer") quotaUsers QuotaUser[] - forms form[] + createdForms Form[] + formResponses FormResponse[] + formReviews FormReview[] + formPermissions FormPermission[] } model AuthSession { @@ -213,6 +216,7 @@ model role { documents document[] @relation("documentTorole") members user[] @relation("roleTouser") roleMembers RoleMember[] + formPermissions FormPermission[] } model RoleMember { @@ -742,120 +746,167 @@ 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]) + pages FormPage[] + responses FormResponse[] + permissions FormPermission[] + @@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[] - - @@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()) + @@index([workspaceGroupId, archived, enabled]) + @@index([workspaceGroupId, createdAt]) +} + +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[] updatedAt DateTime @updatedAt - form form @relation(fields: [formId], references: [id], onDelete: Cascade) - answers formAnswer[] - reviews formReview[] - comments formComment[] - auditLogs formAuditLog[] + @@index([formId]) + @@unique([formId, order]) +} + +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()) + updatedAt DateTime @updatedAt + page FormPage @relation(fields: [pageId], references: [id], onDelete: Cascade) + answers FormAnswer[] + + @@index([pageId]) + @@unique([pageId, order]) +} + +enum FormResponseStatus { + PENDING + IN_REVIEW + APPROVED + DENIED + WITHDRAWN +} + +model FormResponse { + id String @id @default(uuid()) @db.Uuid + formId String @db.Uuid + 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: [submittedById], 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([submittedById]) +} + +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()) + updatedAt DateTime @updatedAt + response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) + reviewer user @relation(fields: [reviewerId], references: [userid]) +} + +enum FormPermissionType { + View + 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 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) @@index([formId]) - @@index([submissionId]) - @@index([workspaceGroupId]) -} \ No newline at end of file + @@index([roleId]) + @@index([userId]) + @@unique([formId, roleId]) + @@unique([formId, userId]) + @@unique([formId, roleId, userId]) +}