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..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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b4d16557..f24ffa71 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,163 @@ 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()) +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 { + 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]) +}