diff --git a/components/settings/general/form.tsx b/components/settings/general/form.tsx new file mode 100644 index 00000000..d8bee354 --- /dev/null +++ b/components/settings/general/form.tsx @@ -0,0 +1,40 @@ +import axios from "axios"; +import React from "react"; +import type toast from "react-hot-toast"; +import { useRecoilState } from "recoil"; +import SwitchComponenet from "@/components/switch"; +import { workspacestate } from "@/state"; +import { FC } from '@/types/settingsComponent' +import { IconForms } from "@tabler/icons-react"; + +type props = { + triggerToast: typeof toast; +} + +const Forms: FC = (props) => { + const triggerToast = props.triggerToast; + const [workspace, setWorkspace] = useRecoilState(workspacestate); + + return ( +
+
+
+ +
+
+

Forms

+

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

+
+
+ +
+ ); +}; + +Forms.title = "Forms"; + +export default Forms; \ No newline at end of file diff --git a/components/settings/general/index.ts b/components/settings/general/index.ts index c963ac3d..ed305641 100644 --- a/components/settings/general/index.ts +++ b/components/settings/general/index.ts @@ -3,6 +3,7 @@ import Guide from './guides' import Alliances from './allies' import Activity from './activity' import home from './home' +import Forms from './form'; import Sessions from './sessions' import Leaderboard from './leaderboard' import Notices from './notices' @@ -11,4 +12,4 @@ import Policies from './policies' import AuditLogs from './logs' import Admin from './admin' import Other from './other' -export { home, Color, Guide, Alliances, Sessions, Activity, Leaderboard, AuditLogs, Policies, Notices, Resignations, Admin, Other }; \ No newline at end of file +export { home, Color, Guide, Forms, Alliances, Sessions, Activity, Leaderboard, AuditLogs, Policies, Notices, Resignations, Admin, Other }; \ No newline at end of file diff --git a/pages/api/forms/helpers.ts b/pages/api/forms/helpers.ts new file mode 100644 index 00000000..d238b6d9 --- /dev/null +++ b/pages/api/forms/helpers.ts @@ -0,0 +1,52 @@ +/** + * Orbit Forms + * Licensed under GPL-3.0 (see LICENSE for details) + * + * Helpers to make life easier in the other scripts + * + * + * @module api/forms + * @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/forms/main.ts b/pages/api/forms/main.ts new file mode 100644 index 00000000..5b2d9f81 --- /dev/null +++ b/pages/api/forms/main.ts @@ -0,0 +1,21 @@ +/** + * Orbit Forms + * Licensed under GPL-3.0 (see LICENSE for details) + * + * Form collection endpoint. + * Used for listing existing forms and creating new forms. + * + * Routes: + * GET /api/forms + * POST /api/forms + * + * Permissions: + * - Forms.View + * - Forms.Create + * + * @module api/forms + * @author BuddyWinte + * @since 2.1.10-beta20 + */ + +import { withAuth } from "@/lib/withAuth"; diff --git a/pages/api/forms/specific.ts b/pages/api/forms/specific.ts new file mode 100644 index 00000000..25f159d8 --- /dev/null +++ b/pages/api/forms/specific.ts @@ -0,0 +1,20 @@ +/** + * Orbit Forms + * Licensed under GPL-3.0 (see LICENSE for details) + * + * Form collection endpoint. + * Used for listing existing forms and creating new forms. + * + * Routes: + * GET /api/forms/:formId + * PATCH /api/forms/:formId + * DELETE /api/forms/:formId + * + * Permissions: + * - Forms.View + * - Forms.Create + * + * @module api/forms + * @author BuddyWinte + * @since 2.1.10-beta20 + */ \ No newline at end of file diff --git a/pages/workspace/[id]/settings.tsx b/pages/workspace/[id]/settings.tsx index 4e122b8d..ec6fb1c9 100644 --- a/pages/workspace/[id]/settings.tsx +++ b/pages/workspace/[id]/settings.tsx @@ -1,142 +1,194 @@ -"use client" - -import type { pageWithLayout } from "@/layoutTypes" -import { loginState } from "@/state" -import { IconHome, IconLock, IconFlag, IconKey, IconServer, IconBellExclamation, IconHourglassHigh, IconLink, IconAdjustments } from "@tabler/icons-react" -import Permissions from "@/components/settings/permissions" -import Workspace from "@/layouts/workspace" -import type { GetServerSideProps } from "next" -import * as All from "@/components/settings/general" -import * as Api from "@/components/settings/api" -import * as Instance from "@/components/settings/instance" -import * as Integrations from "@/components/settings/integration" -import * as cookie from 'cookie' -import toast from "react-hot-toast" -import * as noblox from "noblox.js" -import { withPermissionCheckSsr } from "@/utils/permissionsManager" -import prisma from "@/utils/database" -import { getUsername, getDisplayName, getThumbnail } from "@/utils/userinfoEngine" -import { useState, useEffect } from "react" -import clsx from "clsx" -import { getSessionByToken } from "@/utils/session" - -export const getServerSideProps: GetServerSideProps = withPermissionCheckSsr(async ({ params, res, req }) => { - if (!params?.id) { - res.statusCode = 404 - return { props: {} } +"use client"; + +import type { pageWithLayout } from "@/layoutTypes"; +import { loginState } from "@/state"; +import { + IconHome, + IconLock, + IconFlag, + IconKey, + IconServer, + IconBellExclamation, + IconHourglassHigh, + IconLink, + IconAdjustments, +} from "@tabler/icons-react"; +import Permissions from "@/components/settings/permissions"; +import Workspace from "@/layouts/workspace"; +import type { GetServerSideProps } from "next"; +import * as All from "@/components/settings/general"; +import * as Api from "@/components/settings/api"; +import * as Instance from "@/components/settings/instance"; +import * as Integrations from "@/components/settings/integration"; +import * as cookie from "cookie"; +import toast from "react-hot-toast"; +import * as noblox from "noblox.js"; +import { withPermissionCheckSsr } from "@/utils/permissionsManager"; +import prisma from "@/utils/database"; +import { useRouter } from "next/router"; +import { + getUsername, + getDisplayName, + getThumbnail, +} from "@/utils/userinfoEngine"; +import { useState, useEffect } from "react"; +import clsx from "clsx"; +import { getSessionByToken } from "@/utils/session"; + +const encodeTab = (tab: string) => { + return btoa(tab); +}; + +const decodeTab = (value: string | null) => { + if (!value) return null; + try { + return atob(value); + } catch { + return null; } +}; - const workspaceGroupId = Number.parseInt(params.id as string) - const cookies = cookie.parse(req.headers.cookie || '') - const token = cookies.session_token - if (!token) return { redirect: { destination: '/login', permanent: false } } - - const session = await getSessionByToken(token) - if (!session) return { redirect: { destination: '/login', permanent: false } } - - const currentUserId = session.userId // already a BigInt - - const currentUser = await prisma.user.findFirst({ - where: { userid: currentUserId }, - include: { - workspaceMemberships: { where: { workspaceGroupId } }, - roles: { where: { workspaceGroupId } }, - }, - }) - - const membership = currentUser?.workspaceMemberships?.[0] - const isAdmin = membership?.isAdmin || false - const userPermissions = currentUser?.roles?.[0]?.permissions || [] - - const grouproles = await noblox.getRoles(Number(params.id)) - const users = await prisma.user.findMany({ - where: { - roles: { - some: { - workspaceGroupId: Number.parseInt(params.id as string), - }, +export const getServerSideProps: GetServerSideProps = withPermissionCheckSsr( + async ({ params, res, req }) => { + if (!params?.id) { + res.statusCode = 404; + return { props: {} }; + } + + const workspaceGroupId = Number.parseInt(params.id as string); + const cookies = cookie.parse(req.headers.cookie || ""); + const token = cookies.session_token; + if (!token) + return { redirect: { destination: "/login", permanent: false } }; + + const session = await getSessionByToken(token); + if (!session) + return { redirect: { destination: "/login", permanent: false } }; + + const currentUserId = session.userId; // already a BigInt + + const currentUser = await prisma.user.findFirst({ + where: { userid: currentUserId }, + include: { + workspaceMemberships: { where: { workspaceGroupId } }, + roles: { where: { workspaceGroupId } }, }, - }, - include: { - roles: { - where: { - workspaceGroupId: Number.parseInt(params.id as string), - } + }); + + const membership = currentUser?.workspaceMemberships?.[0]; + const isAdmin = membership?.isAdmin || false; + const userPermissions = currentUser?.roles?.[0]?.permissions || []; + + const grouproles = await noblox.getRoles(Number(params.id)); + const users = await prisma.user.findMany({ + where: { + roles: { + some: { + workspaceGroupId: Number.parseInt(params.id as string), + }, + }, }, - workspaceMemberships: { - where: { - workspaceGroupId: Number.parseInt(params.id as string), + include: { + roles: { + where: { + workspaceGroupId: Number.parseInt(params.id as string), + }, + }, + workspaceMemberships: { + where: { + workspaceGroupId: Number.parseInt(params.id as string), + }, }, }, - }, - }) + }); - const roles = await prisma.role.findMany({ - where: { - workspaceGroupId: Number.parseInt(params.id as string), - } - }) + const roles = await prisma.role.findMany({ + where: { + workspaceGroupId: Number.parseInt(params.id as string), + }, + }); - const departments = await prisma.department.findMany({ - where: { - workspaceGroupId: Number.parseInt(params.id as string), - } - }) - - const usersWithInfo = await Promise.all( - users.map(async (user) => { - const username = user.username || (await getUsername(user.userid)) - const thumbnail = user.picture || getThumbnail(user.userid) - const displayName = user.username || (await getDisplayName(user.userid)) - return { - ...user, - userid: Number(user.userid), - username, - thumbnail, - displayName, - workspaceMemberships: user.workspaceMemberships?.map(m => ({ - ...m, - userId: Number(m.userId), - lineManagerId: m.lineManagerId ? Number(m.lineManagerId) : null, - joinDate: m.joinDate ? m.joinDate.toISOString() : null, + const departments = await prisma.department.findMany({ + where: { + workspaceGroupId: Number.parseInt(params.id as string), + }, + }); + + const usersWithInfo = await Promise.all( + users.map(async (user) => { + const username = user.username || (await getUsername(user.userid)); + const thumbnail = user.picture || getThumbnail(user.userid); + const displayName = + user.username || (await getDisplayName(user.userid)); + return { + ...user, + userid: Number(user.userid), + username, + thumbnail, + displayName, + workspaceMemberships: user.workspaceMemberships?.map((m) => ({ + ...m, + userId: Number(m.userId), + lineManagerId: m.lineManagerId ? Number(m.lineManagerId) : null, + joinDate: m.joinDate ? m.joinDate.toISOString() : null, + })), + }; + }), + ); + + return { + props: { + users: usersWithInfo.map((u) => ({ + ...u, + roles: u.roles.map((r: any) => ({ + ...r, + groupRoles: r.groupRoles.map((id: any) => id.toString()), + })), })), - } - }), - ) - - return { - props: { - users: usersWithInfo.map(u => ({ - ...u, - roles: u.roles.map((r: any) => ({ + roles: roles.map((r) => ({ ...r, - groupRoles: r.groupRoles.map((id: any) => id.toString()) - })) - })), - roles: roles.map(r => ({ - ...r, - groupRoles: r.groupRoles.map((id) => id.toString()) - })), - departments: departments.map(d => ({ - ...d, - createdAt: d.createdAt ? d.createdAt.toISOString() : null, - updatedAt: d.updatedAt ? d.updatedAt.toISOString() : null, - })), - grouproles, - isAdmin, - userPermissions, - }, - } -}, ["admin", "workspace_customisation", "reset_activity", "manage_features", "manage_apikeys", "view_audit_logs"]) + groupRoles: r.groupRoles.map((id) => id.toString()), + })), + departments: departments.map((d) => ({ + ...d, + createdAt: d.createdAt ? d.createdAt.toISOString() : null, + updatedAt: d.updatedAt ? d.updatedAt.toISOString() : null, + })), + grouproles, + isAdmin, + userPermissions, + }, + }; + }, + [ + "admin", + "workspace_customisation", + "reset_activity", + "manage_features", + "manage_apikeys", + "view_audit_logs", + ], +); type Props = { - roles: [] - users: [] - departments: [] - grouproles: [] - isAdmin: boolean - userPermissions: string[] -} + roles: []; + users: []; + departments: []; + grouproles: []; + isAdmin: boolean; + userPermissions: string[]; +}; + +const FEATURE_FLAGS = [ + "Guide", + "Sessions", + "Alliances", + "Leaderboard", + "Notices", + "Resignations", + "Policies", + "Forms", +]; const SECTIONS = { general: { @@ -175,7 +227,7 @@ const SECTIONS = { icon: IconFlag, description: "Enable or disable workspace features", components: Object.entries(All) - .filter(([key]) => key === "Guide" || key === "Sessions" || key === "Alliances" || key === "Leaderboard" || key === "Notices" || key === "Resignations" || key === "Policies") + .filter(([key]) => FEATURE_FLAGS.includes(key)) .map(([key, Component]) => ({ key, component: Component, @@ -217,7 +269,8 @@ const SECTIONS = { integration: { name: "Integrations", icon: IconLink, - description: "Use our integrations that require minimal setup for your experiences.", + description: + "Use our integrations that require minimal setup for your experiences.", components: Object.entries(Integrations).map(([key, Component]) => ({ key, component: Component, @@ -236,54 +289,82 @@ const SECTIONS = { title: Component.title, })), }, -} - -const Settings: pageWithLayout = ({ users, roles, departments, grouproles, isAdmin, userPermissions }) => { - const [activeSection, setActiveSection] = useState("general") - const [isSidebarExpanded] = useState(true) +}; + +const Settings: pageWithLayout = ({ + users, + roles, + departments, + grouproles, + isAdmin, + userPermissions, +}) => { + const [activeSection, setActiveSection] = useState("general"); + const [isSidebarExpanded] = useState(true); const hasPermission = (permission: string) => { return isAdmin || userPermissions.includes(permission); }; - const canAccessGeneral = hasPermission('workspace_customisation'); - const canAccessActivity = hasPermission('reset_activity'); - const canAccessFeatures = hasPermission('manage_features'); - const canAccessApi = hasPermission('manage_apikeys'); - const canAccessPermissions = isAdmin || hasPermission('admin'); // Admins or admin permission - const canAccessAudit = hasPermission('view_audit_logs'); - const canAccessInstance = isAdmin || hasPermission('admin'); // Admins or admin permission + const router = useRouter(); + + useEffect(() => { + const t = router.query.t as string | undefined; + const decoded = decodeTab(t ?? null); + + if (decoded && SECTIONS[decoded as keyof typeof SECTIONS]) { + setActiveSection(decoded); + } + }, [router.query.t]); + + const canAccessGeneral = hasPermission("workspace_customisation"); + const canAccessActivity = hasPermission("reset_activity"); + const canAccessFeatures = hasPermission("manage_features"); + const canAccessApi = hasPermission("manage_apikeys"); + const canAccessPermissions = isAdmin || hasPermission("admin"); // Admins or admin permission + const canAccessAudit = hasPermission("view_audit_logs"); + const canAccessInstance = isAdmin || hasPermission("admin"); // Admins or admin permission const canAccessOther = - hasPermission('manage_features') || hasPermission('workspace_customisation'); + hasPermission("manage_features") || + hasPermission("workspace_customisation"); const availableSections = Object.entries(SECTIONS).filter(([key]) => { - if (key === 'general') return canAccessGeneral; - if (key === 'activity') return canAccessActivity; - if (key === 'features') return canAccessFeatures; - if (key === 'api') return canAccessApi; - if (key === 'permissions') return canAccessPermissions; - if (key === 'audit') return canAccessAudit; - if (key === 'instance') return canAccessInstance; - if (key === 'integration') return canAccessPermissions && canAccessApi; // api access is required, upon download it'll create a key and assign to that user, a key. - if (key === 'other') return canAccessOther; + if (key === "general") return canAccessGeneral; + if (key === "activity") return canAccessActivity; + if (key === "features") return canAccessFeatures; + if (key === "api") return canAccessApi; + if (key === "permissions") return canAccessPermissions; + if (key === "audit") return canAccessAudit; + if (key === "instance") return canAccessInstance; + if (key === "integration") return canAccessPermissions && canAccessApi; // api access is required, upon download it'll create a key and assign to that user, a key. + if (key === "other") return canAccessOther; return false; }); useEffect(() => { - if (availableSections.length > 0 && !availableSections.find(([key]) => key === activeSection)) { + if ( + availableSections.length > 0 && + !availableSections.find(([key]) => key === activeSection) + ) { setActiveSection(availableSections[0][0]); } }, []); - const panelClass = "rounded-2xl bg-white shadow-[0_1px_3px_0_rgb(0,0,0,0.06),0_1px_2px_-1px_rgb(0,0,0,0.04)] dark:bg-zinc-900/70 dark:shadow-zinc-950/30" + const panelClass = + "rounded-2xl bg-white shadow-[0_1px_3px_0_rgb(0,0,0,0.06),0_1px_2px_-1px_rgb(0,0,0,0.04)] dark:bg-zinc-900/70 dark:shadow-zinc-950/30"; const renderContent = () => { if (activeSection === "permissions") { return (
- +
- ) + ); } if (activeSection === "audit") { @@ -291,15 +372,17 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles
- ) + ); } if (activeSection === "api") { - const apiComponents = [...SECTIONS.api.components] - const apiKeyIndex = apiComponents.findIndex(({ key }) => key.toLowerCase().includes("key")) + const apiComponents = [...SECTIONS.api.components]; + const apiKeyIndex = apiComponents.findIndex(({ key }) => + key.toLowerCase().includes("key"), + ); if (apiKeyIndex > 0) { - const [apiKeyComponent] = apiComponents.splice(apiKeyIndex, 1) - apiComponents.unshift(apiKeyComponent) + const [apiKeyComponent] = apiComponents.splice(apiKeyIndex, 1); + apiComponents.unshift(apiKeyComponent); } return (
@@ -309,20 +392,22 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles
))} - ) + ); } if (activeSection === "features") { return (
- {SECTIONS.features.components.map(({ component: Component, key }) => { - const componentProps: any = { triggerToast: toast }; - return ; - })} + {SECTIONS.features.components.map( + ({ component: Component, key }) => { + const componentProps: any = { triggerToast: toast }; + return ; + }, + )}
- ) + ); } const section = SECTIONS[activeSection as keyof typeof SECTIONS]; @@ -332,7 +417,11 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles return (
{section.components.map(({ component: Component, title, key }) => { - const componentProps: any = { triggerToast: toast, isSidebarExpanded, title }; + const componentProps: any = { + triggerToast: toast, + isSidebarExpanded, + title, + }; return (
@@ -345,39 +434,45 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles return (
- {section.components.map(({ component: Component, title, key }, index) => { - const componentProps: any = { triggerToast: toast }; - - if (key === "Admin") { - componentProps.isAdmin = isAdmin; - } else { - componentProps.isSidebarExpanded = isSidebarExpanded; - componentProps.hasResetActivityOnly = - activeSection === "activity" && - !isAdmin && - !userPermissions.includes("workspace_customisation"); - } - - if ((Component as any).isAboveOthers) { - return ; - } - - return ( -
-

{title}

- -
- ); - })} + {section.components.map( + ({ component: Component, title, key }, index) => { + const componentProps: any = { triggerToast: toast }; + + if (key === "Admin") { + componentProps.isAdmin = isAdmin; + } else { + componentProps.isSidebarExpanded = isSidebarExpanded; + componentProps.hasResetActivityOnly = + activeSection === "activity" && + !isAdmin && + !userPermissions.includes("workspace_customisation"); + } + + if ((Component as any).isAboveOthers) { + return ; + } + + return ( +
+

+ {title} +

+ +
+ ); + }, + )}
); - } + }; return (
-

Settings

+

+ Settings +

Manage your workspace preferences and configurations

@@ -387,12 +482,23 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles
@@ -411,10 +517,12 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles

- {SECTIONS[activeSection as keyof typeof SECTIONS]?.name || "Settings"} + {SECTIONS[activeSection as keyof typeof SECTIONS]?.name || + "Settings"}

- {SECTIONS[activeSection as keyof typeof SECTIONS]?.description || "Manage your settings"} + {SECTIONS[activeSection as keyof typeof SECTIONS] + ?.description || "Manage your settings"}

@@ -423,9 +531,9 @@ const Settings: pageWithLayout = ({ users, roles, departments, grouproles
- ) -} + ); +}; -Settings.layout = Workspace +Settings.layout = Workspace; -export default Settings \ No newline at end of file +export default Settings; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48840c0b..fab8586a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,7 @@ model workspace { groupName String? groupLogo String? lastSynced DateTime? - lastSyncedSuccessful Boolean? @default(false) + lastSyncedSuccessful Boolean? @default(false) customName String? memberCount Int? adjustments ActivityAdjustment[] @@ -37,6 +37,7 @@ model workspace { policyLinks PolicyShareableLink[] stickyAnnouncements StickyAnnouncement[] staffResignations staffResignation[] + forms form[] } model config { @@ -58,48 +59,49 @@ model instanceConfig { } model user { - userid BigInt @id @unique - isOwner Boolean? - picture String? - username String? - registered Boolean? - isFirstLogin Boolean? @default(true) - birthdayDay Int? - birthdayMonth Int? - discordUser DiscordUser? - googleUser GoogleUser? - authSessions AuthSession[] - adjustmentsMade ActivityAdjustment[] @relation("AdjustmentActor") - adjustmentsReceived ActivityAdjustment[] @relation("AdjustmentUser") - activityHistory ActivityHistory[] - activityResets ActivityReset[] - activitySessions ActivitySession[] - sessions Session[] - sessionLogsAsActor SessionLog[] @relation("SessionLogActor") - sessionLogsAsTarget SessionLog[] @relation("SessionLogTarget") - sessionNotes SessionNote[] - allyVisits allyVisit[] - apiKey apiKey[] - documents document[] - inactivityNotices inactivityNotice[] - ranks rank[] - sessionsRoles sessionUser[] - writtenBooks userBook[] @relation("bookAdmin") - book userBook[] @relation("bookUser") - info userInfo? - wallPosts wallPost[] - workspaceMemberships workspaceMember[] - managedMembers workspaceMember[] @relation("LineManager") - Ally Ally[] @relation("AllyTouser") - roles role[] @relation("roleTouser") - roleMembers RoleMember[] - policyAcknowledgments PolicyAcknowledgment[] - createdPolicyLinks PolicyShareableLink[] - staffResignations staffResignation[] - resignationsReviewed staffResignation[] @relation("ResignationReviewer") - quotaCustomCompletions QuotaCustomCompletion[] @relation("QuotaCompletionUser") + userid BigInt @id @unique + isOwner Boolean? + picture String? + username String? + registered Boolean? + isFirstLogin Boolean? @default(true) + birthdayDay Int? + birthdayMonth Int? + discordUser DiscordUser? + googleUser GoogleUser? + authSessions AuthSession[] + adjustmentsMade ActivityAdjustment[] @relation("AdjustmentActor") + adjustmentsReceived ActivityAdjustment[] @relation("AdjustmentUser") + activityHistory ActivityHistory[] + activityResets ActivityReset[] + activitySessions ActivitySession[] + sessions Session[] + sessionLogsAsActor SessionLog[] @relation("SessionLogActor") + sessionLogsAsTarget SessionLog[] @relation("SessionLogTarget") + sessionNotes SessionNote[] + allyVisits allyVisit[] + apiKey apiKey[] + documents document[] + inactivityNotices inactivityNotice[] + ranks rank[] + sessionsRoles sessionUser[] + writtenBooks userBook[] @relation("bookAdmin") + book userBook[] @relation("bookUser") + info userInfo? + wallPosts wallPost[] + workspaceMemberships workspaceMember[] + managedMembers workspaceMember[] @relation("LineManager") + Ally Ally[] @relation("AllyTouser") + roles role[] @relation("roleTouser") + roleMembers RoleMember[] + policyAcknowledgments PolicyAcknowledgment[] + createdPolicyLinks PolicyShareableLink[] + staffResignations staffResignation[] + resignationsReviewed staffResignation[] @relation("ResignationReviewer") + quotaCustomCompletions QuotaCustomCompletion[] @relation("QuotaCompletionUser") quotaCompletionsReviewed QuotaCustomCompletion[] @relation("QuotaCompletionReviewer") - quotaUsers QuotaUser[] + quotaUsers QuotaUser[] + forms form[] } model AuthSession { @@ -118,30 +120,33 @@ model AuthSession { } model OAuthState { - id String @id @default(cuid()) - state String @unique - userId BigInt? - provider String @default("roblox") - expiresAt DateTime - createdAt DateTime @default(now()) + id String @id @default(cuid()) + state String @unique + userId BigInt? + provider String @default("roblox") + expiresAt DateTime + createdAt DateTime @default(now()) + @@index([state]) @@index([expiresAt]) } model ValidationState { - id String @id @default(cuid()) - code String @unique - userId BigInt - createdAt DateTime @default(now()) + id String @id @default(cuid()) + code String @unique + userId BigInt + createdAt DateTime @default(now()) + @@index([code]) } model VerificationState { - id String @id @default(cuid()) - code String @unique - userId BigInt - isReset Boolean @default(false) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + code String @unique + userId BigInt + isReset Boolean @default(false) + createdAt DateTime @default(now()) + @@index([code]) } @@ -156,24 +161,24 @@ model pendingVerification { } model DiscordUser { - discordUserId BigInt @id - robloxUserId BigInt? @unique + discordUserId BigInt @id + robloxUserId BigInt? @unique username String avatar String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user user? @relation(fields: [robloxUserId], references: [userid]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user user? @relation(fields: [robloxUserId], references: [userid]) } model GoogleUser { - googleUserId String @id - robloxUserId BigInt? @unique - username String - avatar String? - email String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user user? @relation(fields: [robloxUserId], references: [userid]) + googleUserId String @id + robloxUserId BigInt? @unique + username String + avatar String? + email String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user user? @relation(fields: [robloxUserId], references: [userid]) } model userInfo { @@ -209,35 +214,35 @@ model role { } model RoleMember { - roleId String @db.Uuid - userId BigInt - manuallyAdded Boolean @default(false) - createdAt DateTime @default(now()) - role role @relation(fields: [roleId], references: [id], onDelete: Cascade) - user user @relation(fields: [userId], references: [userid], onDelete: Cascade) + roleId String @db.Uuid + userId BigInt + manuallyAdded Boolean @default(false) + createdAt DateTime @default(now()) + role role @relation(fields: [roleId], references: [id], onDelete: Cascade) + user user @relation(fields: [userId], references: [userid], onDelete: Cascade) @@id([roleId, userId]) } model department { - id String @id @unique @default(uuid()) @db.Uuid - name String - color String? - workspaceGroupId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - departmentMembers DepartmentMember[] - quotaDepartments QuotaDepartment[] - SessionType SessionType[] @relation("SessionTypeTodepartment") - documents document[] @relation("documentTodepartment") + id String @id @unique @default(uuid()) @db.Uuid + name String + color String? + workspaceGroupId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + departmentMembers DepartmentMember[] + quotaDepartments QuotaDepartment[] + SessionType SessionType[] @relation("SessionTypeTodepartment") + documents document[] @relation("documentTodepartment") } model DepartmentMember { - departmentId String @db.Uuid + departmentId String @db.Uuid workspaceGroupId Int userId BigInt - department department @relation(fields: [departmentId], references: [id], onDelete: Cascade) + department department @relation(fields: [departmentId], references: [id], onDelete: Cascade) workspaceMember workspaceMember @relation(fields: [workspaceGroupId, userId], references: [workspaceGroupId, userId], onDelete: Cascade) @@id([departmentId, workspaceGroupId, userId]) @@ -271,7 +276,6 @@ model wallPost { image String? author user @relation(fields: [authorId], references: [userid]) workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - } model StickyAnnouncement { @@ -279,14 +283,14 @@ model StickyAnnouncement { workspaceGroupId Int title String subtitle String? - sections Json // Array of {title: string, content: string} + sections Json // Array of {title: string, content: string} editorId BigInt? editorUsername String? editorPicture String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - + @@unique([workspaceGroupId]) } @@ -343,27 +347,27 @@ model sessionUser { } model Session { - id String @id @unique @default(uuid()) @db.Uuid - ownerId BigInt? - date DateTime - startedAt DateTime? - ended DateTime? - duration Int @default(30) // Duration in minutes - sessionTypeId String @db.Uuid - scheduleId String? @db.Uuid - name String? - type String? - archived Boolean? @default(false) - archiveStartDate DateTime? - archiveEndDate DateTime? - cancelled Boolean? @default(false) - cancellationReason String? - owner user? @relation(fields: [ownerId], references: [userid]) - schedule schedule? @relation(fields: [scheduleId], references: [id]) - sessionType SessionType @relation(fields: [sessionTypeId], references: [id]) - logs SessionLog[] - notes SessionNote[] - users sessionUser[] + id String @id @unique @default(uuid()) @db.Uuid + ownerId BigInt? + date DateTime + startedAt DateTime? + ended DateTime? + duration Int @default(30) // Duration in minutes + sessionTypeId String @db.Uuid + scheduleId String? @db.Uuid + name String? + type String? + archived Boolean? @default(false) + archiveStartDate DateTime? + archiveEndDate DateTime? + cancelled Boolean? @default(false) + cancellationReason String? + owner user? @relation(fields: [ownerId], references: [userid]) + schedule schedule? @relation(fields: [scheduleId], references: [id]) + sessionType SessionType @relation(fields: [sessionTypeId], references: [id]) + logs SessionLog[] + notes SessionNote[] + users sessionUser[] } model SessionNote { @@ -532,51 +536,51 @@ model rank { } model Quota { - id String @id @default(uuid()) @db.Uuid - type String - value Int? - workspaceGroupId Int - name String - description String? - sessionType String? - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - quotaRoles QuotaRole[] - quotaDepartments QuotaDepartment[] - quotaUsers QuotaUser[] + id String @id @default(uuid()) @db.Uuid + type String + value Int? + workspaceGroupId Int + name String + description String? + sessionType String? + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + quotaRoles QuotaRole[] + quotaDepartments QuotaDepartment[] + quotaUsers QuotaUser[] customCompletions QuotaCustomCompletion[] } model QuotaCustomCompletion { - id String @id @default(uuid()) @db.Uuid - quotaId String @db.Uuid - userId BigInt - status String - submittedAt DateTime @default(now()) - reviewedAt DateTime? - reviewedByUserId BigInt? - quota Quota @relation(fields: [quotaId], references: [id], onDelete: Cascade) - user user @relation("QuotaCompletionUser", fields: [userId], references: [userid], onDelete: Cascade) - reviewedBy user? @relation("QuotaCompletionReviewer", fields: [reviewedByUserId], references: [userid], onDelete: SetNull) + id String @id @default(uuid()) @db.Uuid + quotaId String @db.Uuid + userId BigInt + status String + submittedAt DateTime @default(now()) + reviewedAt DateTime? + reviewedByUserId BigInt? + quota Quota @relation(fields: [quotaId], references: [id], onDelete: Cascade) + user user @relation("QuotaCompletionUser", fields: [userId], references: [userid], onDelete: Cascade) + reviewedBy user? @relation("QuotaCompletionReviewer", fields: [reviewedByUserId], references: [userid], onDelete: SetNull) @@unique([quotaId, userId]) @@index([quotaId, status]) } model Ally { - workspaceGroupId Int - name String - icon String - groupId String - notes String[] - discordServer String? - theirReps String[] - strikes Int @default(0) - terminationEffectiveDate DateTime? - terminationReason String? - id String @id @unique @default(uuid()) @db.Uuid - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - allyVisits allyVisit[] - reps user[] @relation("AllyTouser") + workspaceGroupId Int + name String + icon String + groupId String + notes String[] + discordServer String? + theirReps String[] + strikes Int @default(0) + terminationEffectiveDate DateTime? + terminationReason String? + id String @id @unique @default(uuid()) @db.Uuid + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + allyVisits allyVisit[] + reps user[] @relation("AllyTouser") } model allyVisit { @@ -591,21 +595,21 @@ model allyVisit { } model workspaceMember { - workspaceGroupId Int - userId BigInt - joinDate DateTime? - birthdayDay Int? - birthdayMonth Int? - lineManagerId BigInt? - timezone String? - discordId String? - isAdmin Boolean? @default(false) - introNote String? - introSong String? - user user @relation(fields: [userId], references: [userid]) - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - lineManager user? @relation("LineManager", fields: [lineManagerId], references: [userid], onDelete: NoAction, onUpdate: NoAction) - departmentMembers DepartmentMember[] + workspaceGroupId Int + userId BigInt + joinDate DateTime? + birthdayDay Int? + birthdayMonth Int? + lineManagerId BigInt? + timezone String? + discordId String? + isAdmin Boolean? @default(false) + introNote String? + introSong String? + user user @relation(fields: [userId], references: [userid]) + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + lineManager user? @relation("LineManager", fields: [lineManagerId], references: [userid], onDelete: NoAction, onUpdate: NoAction) + departmentMembers DepartmentMember[] @@id([workspaceGroupId, userId]) @@index([userId]) @@ -670,44 +674,44 @@ model ActivityReset { } model workspaceExternalServices { - id Int @id @default(autoincrement()) - workspaceGroupId Int @unique - rankingProvider String? - rankingToken String? + id Int @id @default(autoincrement()) + workspaceGroupId Int @unique + rankingProvider String? + rankingToken String? rankingWorkspaceId String? - rankingMaxRank Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + rankingMaxRank Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) @@index([workspaceGroupId]) } model SavedView { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid workspaceGroupId Int name String color String? icon String? filters Json columnVisibility Json - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) @@index([workspaceGroupId]) } model PolicyAcknowledgment { - id String @id @default(uuid()) @db.Uuid - userId BigInt - documentId String @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId BigInt + documentId String @db.Uuid acknowledgedAt DateTime @default(now()) - ipAddress String? - signature String? // Digital signature or acknowledgment text - isRequired Boolean @default(false) - user user @relation(fields: [userId], references: [userid]) - document document @relation(fields: [documentId], references: [id], onDelete: Cascade) + ipAddress String? + signature String? // Digital signature or acknowledgment text + isRequired Boolean @default(false) + user user @relation(fields: [userId], references: [userid]) + document document @relation(fields: [documentId], references: [id], onDelete: Cascade) @@unique([userId, documentId]) @@index([userId]) @@ -715,23 +719,141 @@ model PolicyAcknowledgment { } model PolicyShareableLink { - id String @id @default(uuid()) @db.Uuid - documentId String @db.Uuid + id String @id @default(uuid()) @db.Uuid + documentId String @db.Uuid workspaceGroupId Int - name String // User-defined name for the link - description String? // Optional description - createdById BigInt - createdAt DateTime @default(now()) - expiresAt DateTime? - isActive Boolean @default(true) - accessCount Int @default(0) - lastAccessed DateTime? - - document document @relation(fields: [documentId], references: [id], onDelete: Cascade) - workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) - createdBy user @relation(fields: [createdById], references: [userid]) + name String // User-defined name for the link + description String? // Optional description + createdById BigInt + createdAt DateTime @default(now()) + expiresAt DateTime? + isActive Boolean @default(true) + accessCount Int @default(0) + lastAccessed DateTime? + + document document @relation(fields: [documentId], references: [id], onDelete: Cascade) + workspace workspace @relation(fields: [workspaceGroupId], references: [groupId]) + createdBy user @relation(fields: [createdById], references: [userid]) @@index([documentId]) @@index([workspaceGroupId]) @@index([createdById]) } + +model form { + id String @id @default(uuid()) @db.Uuid + workspaceGroupId Int + 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[] + + @@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()) + updatedAt DateTime @updatedAt + form form @relation(fields: [formId], references: [id], onDelete: Cascade) + answers formAnswer[] + reviews formReview[] + comments formComment[] + auditLogs formAuditLog[] + + @@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([formId]) + @@index([submissionId]) + @@index([workspaceGroupId]) +}