From 9698428dd91a7483264148c03181c994b4356b42 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 15 Feb 2026 22:01:46 -0700 Subject: [PATCH 1/2] feat(security): add multi-tenancy isolation and demo mode Add org-scoped data isolation across all server actions to prevent cross-org data leakage. Add read-only demo mode with mutation guards on all write endpoints. Multi-tenancy: - org filter on executeDashboardQueries (all query types) - org boundary checks on getChannel, joinChannel - searchMentionableUsers derives org from session - getConversationUsage scoped to user, not org-wide for admins - organizations table, members, org switcher component Demo mode: - /demo route sets strict sameSite cookie - isDemoUser guards on all mutation server actions - demo banner, CTA dialog, and gate components - seed script for demo org data Also: exclude scripts/ from tsconfig (fixes build), add multi-tenancy architecture documentation. --- docs/README.md | 1 + docs/architecture/multi-tenancy.md | 328 ++ drizzle/0026_easy_professor_monster.sql | 2 + drizzle/meta/0026_snapshot.json | 5023 +++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 2 + scripts/migrate-to-orgs.ts | 142 + scripts/seed-demo.ts | 284 + src/app/actions/agent.ts | 9 + src/app/actions/ai-config.ts | 15 +- src/app/actions/baselines.ts | 54 +- src/app/actions/channel-categories.ts | 101 +- src/app/actions/chat-messages.ts | 22 +- src/app/actions/conversations.ts | 41 +- src/app/actions/credit-memos.ts | 111 +- src/app/actions/customers.ts | 43 +- src/app/actions/dashboards.ts | 142 +- src/app/actions/groups.ts | 34 +- src/app/actions/invoices.ts | 129 +- src/app/actions/mcp-keys.ts | 13 + src/app/actions/organizations.ts | 127 +- src/app/actions/payments.ts | 109 +- src/app/actions/plugins.ts | 13 + src/app/actions/projects.ts | 8 +- src/app/actions/schedule.ts | 138 +- src/app/actions/teams.ts | 34 +- src/app/actions/themes.ts | 9 + src/app/actions/vendor-bills.ts | 129 +- src/app/actions/vendors.ts | 43 +- src/app/actions/workday-exceptions.ts | 73 +- src/app/api/agent/route.ts | 6 +- src/app/api/sync/mutate/route.ts | 12 +- src/app/dashboard/layout.tsx | 17 +- src/app/demo/route.ts | 14 + src/app/page.tsx | 6 +- src/components/app-sidebar.tsx | 6 + .../conversations/mention-suggestion.tsx | 2 +- src/components/demo/demo-banner.tsx | 45 + src/components/demo/demo-cta-dialog.tsx | 40 + src/components/demo/demo-gate.tsx | 31 + src/components/native/biometric-guard.tsx | 8 +- src/components/org-switcher.tsx | 161 + src/db/schema.ts | 2 + src/lib/agent/system-prompt.ts | 36 +- src/lib/agent/tools.ts | 150 +- src/lib/auth.ts | 96 +- src/lib/demo.ts | 30 + src/lib/org-scope.ts | 8 + src/middleware.ts | 7 + tsconfig.json | 2 +- 50 files changed, 7589 insertions(+), 276 deletions(-) create mode 100644 docs/architecture/multi-tenancy.md create mode 100644 drizzle/0026_easy_professor_monster.sql create mode 100644 drizzle/meta/0026_snapshot.json create mode 100644 scripts/migrate-to-orgs.ts create mode 100644 scripts/seed-demo.ts create mode 100644 src/app/demo/route.ts create mode 100644 src/components/demo/demo-banner.tsx create mode 100644 src/components/demo/demo-cta-dialog.tsx create mode 100644 src/components/demo/demo-gate.tsx create mode 100644 src/components/org-switcher.tsx create mode 100644 src/lib/demo.ts create mode 100644 src/lib/org-scope.ts diff --git a/docs/README.md b/docs/README.md index 487843c..873385a 100755 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ How the core platform works. - [server actions](architecture/server-actions.md) -- the data mutation pattern, auth checks, error handling, revalidation - [auth system](architecture/auth-system.md) -- WorkOS integration, middleware, session management, RBAC - [AI agent](architecture/ai-agent.md) -- OpenRouter provider, tool system, system prompt, unified chat architecture, usage tracking +- [multi-tenancy](architecture/multi-tenancy.md) -- org isolation, demo mode guards, the requireOrg pattern, adding new server actions safely modules diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md new file mode 100644 index 0000000..845b2bf --- /dev/null +++ b/docs/architecture/multi-tenancy.md @@ -0,0 +1,328 @@ +Multi-Tenancy and Data Isolation +=== + +Compass is a multi-tenant application. Multiple organizations share +the same database, the same workers, and the same codebase. This +means every query that touches user-facing data must be scoped to +the requesting user's organization, or you've built a system where +one customer can read another customer's financials. + +This document covers the isolation model, the demo mode guardrails, +and the specific patterns developers need to follow when adding new +server actions or queries. + + +the threat model +--- + +Multi-tenancy bugs are quiet. They don't throw errors. They don't +crash the page. They return perfectly valid data -- it just belongs +to someone else. A user won't notice they're seeing invoices from +another org unless the numbers look wrong. An attacker, however, +will notice immediately. + +The attack surface is server actions. Every exported function in +`src/app/actions/` is callable from the client. If a server action +takes an ID and fetches a record without checking that the record +belongs to the caller's org, any authenticated user can read any +record in the database by guessing or enumerating IDs. + +The second concern is demo mode. Demo users get an authenticated +session (they need one to browse the app), but they should never +be able to write persistent state. Without explicit guards, a demo +user's "save" buttons work just like a real user's. + + +the org scope pattern +--- + +Every server action that reads or writes org-scoped data should +call `requireOrg(user)` immediately after authentication. This +function lives in `src/lib/org-scope.ts` and does one thing: +extracts the user's active organization ID, throwing if there +isn't one. + +```typescript +import { requireOrg } from "@/lib/org-scope" + +const user = await getCurrentUser() +if (!user) return { success: false, error: "Unauthorized" } + +const orgId = requireOrg(user) +``` + +The org ID comes from the user's session, not from client input. +This is important -- if the client sends an `organizationId` +parameter, an attacker controls it. The server derives it from +the authenticated session, so the user can only access their own +org's data. + + +filtering by org +--- + +Tables fall into two categories: those with a direct +`organizationId` column, and those that reference org-scoped data +through a foreign key. + +**Direct org column**: `customers`, `vendors`, `projects`, +`channels`, `teams`, `groups`. These are straightforward: + +```typescript +const rows = await db.query.customers.findMany({ + where: (c, { eq }) => eq(c.organizationId, orgId), + limit: cap, +}) +``` + +When combining org filtering with search, use `and()`: + +```typescript +where: (c, { eq, like, and }) => + and( + eq(c.organizationId, orgId), + like(c.name, `%${search}%`), + ) +``` + +**Indirect org reference**: `invoices`, `vendor_bills`, +`schedule_tasks`, `task_dependencies`. These don't have an +`organizationId` column -- they reference `projects`, which +does. The pattern is to first resolve the set of project IDs +belonging to the org, then filter using `inArray`: + +```typescript +const orgProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.organizationId, orgId)) + +const projectIds = orgProjects.map(p => p.id) + +const rows = projectIds.length > 0 + ? await db.query.invoices.findMany({ + where: (inv, { inArray }) => + inArray(inv.projectId, projectIds), + limit: cap, + }) + : [] +``` + +The `projectIds.length > 0` guard matters because `inArray` +with an empty array produces invalid SQL in some drivers. + +**Detail queries** (fetching a single record by ID) should +verify ownership after the fetch: + +```typescript +const row = await db.query.projects.findFirst({ + where: (p, { eq: e }) => e(p.id, projectId), +}) + +if (!row || row.organizationId !== orgId) { + return { success: false, error: "not found" } +} +``` + +Returning "not found" rather than "access denied" is deliberate. +It avoids leaking the existence of records in other orgs. + + +why not a global middleware? +--- + +It might seem cleaner to add org filtering at the database layer +-- a global query modifier or a Drizzle plugin that automatically +injects `WHERE organization_id = ?` on every query. We considered +this and decided against it for three reasons. + +First, not every table has an `organizationId` column. The +indirect-reference tables (invoices, schedule tasks) need joins +or subqueries, which a generic filter can't handle without +understanding the schema relationships. + +Second, some queries are intentionally cross-org. The WorkOS +integration, for instance, needs to look up users across +organizations during directory sync. A global filter would need +an escape hatch, and escape hatches in security code tend to get +used carelessly. + +Third, explicit filtering is auditable. When every server action +visibly calls `requireOrg(user)` and adds the filter, a reviewer +can see at a glance whether the query is scoped. Implicit +filtering hides the mechanism, making it harder to verify and +easier to accidentally bypass. + +The tradeoff is boilerplate. Every new server action needs the +same three lines. We accept this cost because security-critical +code should be boring and obvious, not clever and hidden. + + +demo mode +--- + +Demo mode gives unauthenticated visitors a read-only experience +of the application. When a user visits `/demo`, they get a +session cookie (`compass-demo`) that identifies them as a +synthetic demo user. This user has an admin role in a demo org +called "Meridian Group", so they can see the full UI, but they +should never be able to modify persistent state. + +The demo user is defined in `src/lib/demo.ts`: + +```typescript +export const DEMO_USER_ID = "demo-user-001" +export const DEMO_ORG_ID = "demo-org-meridian" + +export function isDemoUser(userId: string): boolean { + return userId === DEMO_USER_ID +} +``` + +Every mutating server action must check `isDemoUser` after +authentication and before any writes: + +```typescript +if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } +} +``` + +The `DEMO_READ_ONLY` error string is a convention. Client +components can check for this specific value to show a +"this action is disabled in demo mode" toast instead of a +generic error. + +**Which actions need the guard**: any function that calls +`db.insert()`, `db.update()`, or `db.delete()`. Read-only +actions don't need it -- demo users should be able to browse +freely. + +**Where to place it**: immediately after the auth check, before +any database access. This keeps the pattern consistent across +all server action files and prevents accidental writes from +queries that run before the guard. + + +the demo cookie +--- + +The `compass-demo` cookie uses `sameSite: "strict"` rather than +`"lax"`. This matters because the cookie bypasses the entire +authentication flow -- if it's present and set to `"true"`, +`getCurrentUser()` returns the demo user without checking WorkOS +at all. With `"lax"`, the cookie would be sent on cross-site +top-level navigations (clicking a link from another site to +Compass). With `"strict"`, it's only sent on same-site requests. + +The `compass-active-org` cookie (which tracks which org a real +user has selected) can remain `"lax"` because it doesn't bypass +authentication. It only influences which org's data is shown +after the user has already been authenticated through WorkOS. + + +files involved +--- + +The org scope and demo guard patterns are applied across these +server action files: + +- `src/app/actions/dashboards.ts` -- org filtering on all + dashboard query types (customers, vendors, projects, invoices, + vendor bills, schedule tasks, and detail queries). Demo guards + on save and delete. + +- `src/app/actions/conversations.ts` -- org boundary check on + `getChannel` and `joinChannel`. Without this, a user who knows + a channel ID from another org could read messages or join the + channel. + +- `src/app/actions/chat-messages.ts` -- `searchMentionableUsers` + derives org from session rather than accepting it as a client + parameter. This prevents a client from searching users in + other orgs by passing a different organization ID. + +- `src/app/actions/ai-config.ts` -- `getConversationUsage` + always filters by user ID, even for admins. An admin in org A + has no business seeing token usage from org B, even if the + admin permission technically allows broader access. + +- `src/app/actions/plugins.ts` -- demo guards on install, + uninstall, and toggle. + +- `src/app/actions/themes.ts` -- demo guards on save and delete. + +- `src/app/actions/mcp-keys.ts` -- demo guards on create, + revoke, and delete. + +- `src/app/actions/agent.ts` -- demo guards on save and delete + conversation. + +- `src/app/demo/route.ts` -- demo cookie set with + `sameSite: "strict"`. + +- `src/lib/org-scope.ts` -- the `requireOrg` utility. + +- `src/lib/demo.ts` -- demo user constants and `isDemoUser` + check. + + +adding a new server action +--- + +When writing a new server action that touches org-scoped data, +follow this pattern: + +```typescript +"use server" + +import { getCurrentUser } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" + +export async function myAction(input: string) { + const user = await getCurrentUser() + if (!user) return { success: false, error: "Unauthorized" } + + // demo guard (only for mutations) + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + + // org scope (for any org-scoped data access) + const orgId = requireOrg(user) + + // ... query with orgId filter +} +``` + +The order matters: authenticate, check demo, scope org, then +query. If you reverse the demo check and org scope, a demo user +without an org would get a confusing "no active organization" +error instead of the intended "demo read only" message. + + +known limitations +--- + +The org scope is enforced at the application layer, not the +database layer. This means a bug in a server action can still +leak data. SQLite (D1) doesn't support row-level security +policies the way PostgreSQL does, so there's no database-level +safety net. The mitigation is code review discipline: every PR +that adds or modifies a server action should be checked for +`requireOrg` usage. + +The demo guard is also application-layer. If someone finds a +server action without the guard, they can mutate state through +the demo session. The mitigation is the same: review discipline +and periodic audits of server action files. + +Both of these limitations would be addressed by moving to +PostgreSQL with row-level security in the future. That's a +significant migration, and the current approach is adequate for +the threat model (authenticated users in a B2B SaaS context, +not anonymous public access). But it's worth noting that the +current security model depends on developers getting every +server action right, rather than the database enforcing it +automatically. diff --git a/drizzle/0026_easy_professor_monster.sql b/drizzle/0026_easy_professor_monster.sql new file mode 100644 index 0000000..a3efad1 --- /dev/null +++ b/drizzle/0026_easy_professor_monster.sql @@ -0,0 +1,2 @@ +ALTER TABLE `customers` ADD `organization_id` text REFERENCES organizations(id);--> statement-breakpoint +ALTER TABLE `vendors` ADD `organization_id` text REFERENCES organizations(id); \ No newline at end of file diff --git a/drizzle/meta/0026_snapshot.json b/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000..221499c --- /dev/null +++ b/drizzle/meta/0026_snapshot.json @@ -0,0 +1,5023 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cd7a2539-1a72-4214-a027-aed9c1a638be", + "prevId": "59d2e199-d658-40bd-9e6e-841b0134d348", + "tables": { + "agent_conversations": { + "name": "agent_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_conversations_user_id_users_id_fk": { + "name": "agent_conversations_user_id_users_id_fk", + "tableFrom": "agent_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_memories": { + "name": "agent_memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embedding": { + "name": "embedding", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_memories_conversation_id_agent_conversations_id_fk": { + "name": "agent_memories_conversation_id_agent_conversations_id_fk", + "tableFrom": "agent_memories", + "tableTo": "agent_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memories_user_id_users_id_fk": { + "name": "agent_memories_user_id_users_id_fk", + "tableFrom": "agent_memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "customers_organization_id_organizations_id_fk": { + "name": "customers_organization_id_organizations_id_fk", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_width": { + "name": "viewport_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_height": { + "name": "viewport_height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback_interviews": { + "name": "feedback_interviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_role": { + "name": "user_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pain_points": { + "name": "pain_points", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feature_requests": { + "name": "feature_requests", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overall_sentiment": { + "name": "overall_sentiment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "feedback_interviews_user_id_users_id_fk": { + "name": "feedback_interviews_user_id_users_id_fk", + "tableFrom": "feedback_interviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group_members": { + "name": "group_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_members_group_id_groups_id_fk": { + "name": "group_members_group_id_groups_id_fk", + "tableFrom": "group_members", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_members_user_id_users_id_fk": { + "name": "group_members_user_id_users_id_fk", + "tableFrom": "group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groups_organization_id_organizations_id_fk": { + "name": "groups_organization_id_organizations_id_fk", + "tableFrom": "groups", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_members": { + "name": "project_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'OPEN'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_manager": { + "name": "project_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_job_id": { + "name": "netsuite_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_tokens": { + "name": "push_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "push_tokens_user_id_users_id_fk": { + "name": "push_tokens_user_id_users_id_fk", + "tableFrom": "push_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_baselines": { + "name": "schedule_baselines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_baselines_project_id_projects_id_fk": { + "name": "schedule_baselines_project_id_projects_id_fk", + "tableFrom": "schedule_baselines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_tasks": { + "name": "schedule_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workdays": { + "name": "workdays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date_calculated": { + "name": "end_date_calculated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PENDING'" + }, + "is_critical_path": { + "name": "is_critical_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_milestone": { + "name": "is_milestone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "percent_complete": { + "name": "percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_tasks_project_id_projects_id_fk": { + "name": "schedule_tasks_project_id_projects_id_fk", + "tableFrom": "schedule_tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slab_memories": { + "name": "slab_memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_type": { + "name": "memory_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importance": { + "name": "importance", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.7 + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "slab_memories_user_id_users_id_fk": { + "name": "slab_memories_user_id_users_id_fk", + "tableFrom": "slab_memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "predecessor_id": { + "name": "predecessor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "successor_id": { + "name": "successor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'FS'" + }, + "lag_days": { + "name": "lag_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_predecessor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_predecessor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "predecessor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_successor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_successor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_members": { + "name": "team_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'office'" + }, + "google_email": { + "name": "google_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendors": { + "name": "vendors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Subcontractor'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vendors_organization_id_organizations_id_fk": { + "name": "vendors_organization_id_organizations_id_fk", + "tableFrom": "vendors", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workday_exceptions": { + "name": "workday_exceptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'non_working'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company_holiday'" + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'one_time'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workday_exceptions_project_id_projects_id_fk": { + "name": "workday_exceptions_project_id_projects_id_fk", + "tableFrom": "workday_exceptions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_memos": { + "name": "credit_memos", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memo_number": { + "name": "memo_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_applied": { + "name": "amount_applied", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_remaining": { + "name": "amount_remaining", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "credit_memos_customer_id_customers_id_fk": { + "name": "credit_memos_customer_id_customers_id_fk", + "tableFrom": "credit_memos", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credit_memos_project_id_projects_id_fk": { + "name": "credit_memos_project_id_projects_id_fk", + "tableFrom": "credit_memos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtotal": { + "name": "subtotal", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax": { + "name": "tax", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_due": { + "name": "amount_due", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invoices_customer_id_customers_id_fk": { + "name": "invoices_customer_id_customers_id_fk", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_project_id_projects_id_fk": { + "name": "invoices_project_id_projects_id_fk", + "tableFrom": "invoices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_auth": { + "name": "netsuite_auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_sync_log": { + "name": "netsuite_sync_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sync_type": { + "name": "sync_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "records_processed": { + "name": "records_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_failed": { + "name": "records_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_sync_metadata": { + "name": "netsuite_sync_metadata", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "local_table": { + "name": "local_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_record_id": { + "name": "local_record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "netsuite_record_type": { + "name": "netsuite_record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "netsuite_internal_id": { + "name": "netsuite_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified_local": { + "name": "last_modified_local", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified_remote": { + "name": "last_modified_remote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'synced'" + }, + "conflict_data": { + "name": "conflict_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_type": { + "name": "payment_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_date": { + "name": "payment_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "payments_customer_id_customers_id_fk": { + "name": "payments_customer_id_customers_id_fk", + "tableFrom": "payments", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_vendor_id_vendors_id_fk": { + "name": "payments_vendor_id_vendors_id_fk", + "tableFrom": "payments", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_project_id_projects_id_fk": { + "name": "payments_project_id_projects_id_fk", + "tableFrom": "payments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendor_bills": { + "name": "vendor_bills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bill_number": { + "name": "bill_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "bill_date": { + "name": "bill_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtotal": { + "name": "subtotal", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax": { + "name": "tax", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_due": { + "name": "amount_due", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vendor_bills_vendor_id_vendors_id_fk": { + "name": "vendor_bills_vendor_id_vendors_id_fk", + "tableFrom": "vendor_bills", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vendor_bills_project_id_projects_id_fk": { + "name": "vendor_bills_project_id_projects_id_fk", + "tableFrom": "vendor_bills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_config": { + "name": "plugin_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_events": { + "name": "plugin_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_events_plugin_id_plugins_id_fk": { + "name": "plugin_events_plugin_id_plugins_id_fk", + "tableFrom": "plugin_events", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_events_user_id_users_id_fk": { + "name": "plugin_events_user_id_users_id_fk", + "tableFrom": "plugin_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_env_vars": { + "name": "required_env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_by": { + "name": "enabled_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_enabled_by_users_id_fk": { + "name": "plugins_enabled_by_users_id_fk", + "tableFrom": "plugins", + "tableTo": "users", + "columnsFrom": [ + "enabled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_items": { + "name": "agent_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_items_user_id_users_id_fk": { + "name": "agent_items_user_id_users_id_fk", + "tableFrom": "agent_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_config": { + "name": "agent_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_cost": { + "name": "prompt_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completion_cost": { + "name": "completion_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_cost_per_million": { + "name": "max_cost_per_million", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_user_selection": { + "name": "allow_user_selection", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_config_updated_by_users_id_fk": { + "name": "agent_config_updated_by_users_id_fk", + "tableFrom": "agent_config", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_usage": { + "name": "agent_usage", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_usage_conversation_id_agent_conversations_id_fk": { + "name": "agent_usage_conversation_id_agent_conversations_id_fk", + "tableFrom": "agent_usage", + "tableTo": "agent_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_usage_user_id_users_id_fk": { + "name": "agent_usage_user_id_users_id_fk", + "tableFrom": "agent_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_model_preference": { + "name": "user_model_preference", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_cost": { + "name": "prompt_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completion_cost": { + "name": "completion_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_preference_user_id_users_id_fk": { + "name": "user_model_preference_user_id_users_id_fk", + "tableFrom": "user_model_preference", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_themes": { + "name": "custom_themes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "theme_data": { + "name": "theme_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_themes_user_id_users_id_fk": { + "name": "custom_themes_user_id_users_id_fk", + "tableFrom": "custom_themes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_theme_preference": { + "name": "user_theme_preference", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active_theme_id": { + "name": "active_theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_theme_preference_user_id_users_id_fk": { + "name": "user_theme_preference_user_id_users_id_fk", + "tableFrom": "user_theme_preference", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_auth": { + "name": "google_auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_account_key_encrypted": { + "name": "service_account_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_domain": { + "name": "workspace_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared_drive_id": { + "name": "shared_drive_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_drive_name": { + "name": "shared_drive_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "google_auth_organization_id_organizations_id_fk": { + "name": "google_auth_organization_id_organizations_id_fk", + "tableFrom": "google_auth", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_auth_connected_by_users_id_fk": { + "name": "google_auth_connected_by_users_id_fk", + "tableFrom": "google_auth", + "tableTo": "users", + "columnsFrom": [ + "connected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_starred_files": { + "name": "google_starred_files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "google_file_id": { + "name": "google_file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "google_starred_files_user_id_users_id_fk": { + "name": "google_starred_files_user_id_users_id_fk", + "tableFrom": "google_starred_files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_dashboards": { + "name": "custom_dashboards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "spec_data": { + "name": "spec_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queries": { + "name": "queries", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "render_prompt": { + "name": "render_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_dashboards_user_id_users_id_fk": { + "name": "custom_dashboards_user_id_users_id_fk", + "tableFrom": "custom_dashboards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_api_keys": { + "name": "mcp_api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_api_keys_user_id_users_id_fk": { + "name": "mcp_api_keys_user_id_users_id_fk", + "tableFrom": "mcp_api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_usage": { + "name": "mcp_usage", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_usage_api_key_id_mcp_api_keys_id_fk": { + "name": "mcp_usage_api_key_id_mcp_api_keys_id_fk", + "tableFrom": "mcp_usage", + "tableTo": "mcp_api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_usage_user_id_users_id_fk": { + "name": "mcp_usage_user_id_users_id_fk", + "tableFrom": "mcp_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_categories": { + "name": "channel_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "collapsed_by_default": { + "name": "collapsed_by_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "channel_categories_organization_id_organizations_id_fk": { + "name": "channel_categories_organization_id_organizations_id_fk", + "tableFrom": "channel_categories", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_members": { + "name": "channel_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "notify_level": { + "name": "notify_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "channel_members_channel_id_channels_id_fk": { + "name": "channel_members_channel_id_channels_id_fk", + "tableFrom": "channel_members", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_members_user_id_users_id_fk": { + "name": "channel_members_user_id_users_id_fk", + "tableFrom": "channel_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_read_state": { + "name": "channel_read_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "channel_read_state_user_id_users_id_fk": { + "name": "channel_read_state_user_id_users_id_fk", + "tableFrom": "channel_read_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_read_state_channel_id_channels_id_fk": { + "name": "channel_read_state_channel_id_channels_id_fk", + "tableFrom": "channel_read_state", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "channels_organization_id_organizations_id_fk": { + "name": "channels_organization_id_organizations_id_fk", + "tableFrom": "channels", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channels_project_id_projects_id_fk": { + "name": "channels_project_id_projects_id_fk", + "tableFrom": "channels", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channels_category_id_channel_categories_id_fk": { + "name": "channels_category_id_channel_categories_id_fk", + "tableFrom": "channels", + "tableTo": "channel_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_created_by_users_id_fk": { + "name": "channels_created_by_users_id_fk", + "tableFrom": "channels", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_attachments": { + "name": "message_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_path": { + "name": "r2_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_attachments_message_id_messages_id_fk": { + "name": "message_attachments_message_id_messages_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_mentions": { + "name": "message_mentions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mention_type": { + "name": "mention_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_mentions_message_id_messages_id_fk": { + "name": "message_mentions_message_id_messages_id_fk", + "tableFrom": "message_mentions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_reactions": { + "name": "message_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_reactions_message_id_messages_id_fk": { + "name": "message_reactions_message_id_messages_id_fk", + "tableFrom": "message_reactions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_reactions_user_id_users_id_fk": { + "name": "message_reactions_user_id_users_id_fk", + "tableFrom": "message_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_html": { + "name": "content_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_reply_at": { + "name": "last_reply_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_deleted_by_users_id_fk": { + "name": "messages_deleted_by_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "deleted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "typing_sessions": { + "name": "typing_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "typing_sessions_channel_id_channels_id_fk": { + "name": "typing_sessions_channel_id_channels_id_fk", + "tableFrom": "typing_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "typing_sessions_user_id_users_id_fk": { + "name": "typing_sessions_user_id_users_id_fk", + "tableFrom": "typing_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_presence": { + "name": "user_presence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'offline'" + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_presence_user_id_users_id_fk": { + "name": "user_presence_user_id_users_id_fk", + "tableFrom": "user_presence", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_sync_metadata": { + "name": "local_sync_metadata", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vector_clock": { + "name": "vector_clock", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified_at": { + "name": "last_modified_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending_sync'" + }, + "conflict_data": { + "name": "conflict_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "local_sync_metadata_table_record_idx": { + "name": "local_sync_metadata_table_record_idx", + "columns": [ + "table_name", + "record_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mutation_queue": { + "name": "mutation_queue", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vector_clock": { + "name": "vector_clock", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "process_after": { + "name": "process_after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "mutation_queue_status_created_idx": { + "name": "mutation_queue_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_checkpoint": { + "name": "sync_checkpoint", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_cursor": { + "name": "last_sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_vector_clock": { + "name": "local_vector_clock", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sync_checkpoint_table_name_unique": { + "name": "sync_checkpoint_table_name_unique", + "columns": [ + "table_name" + ], + "isUnique": true + }, + "sync_checkpoint_table_name_idx": { + "name": "sync_checkpoint_table_name_idx", + "columns": [ + "table_name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstone": { + "name": "sync_tombstone", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vector_clock": { + "name": "vector_clock", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced": { + "name": "synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "sync_tombstone_table_record_idx": { + "name": "sync_tombstone_table_record_idx", + "columns": [ + "table_name", + "record_id" + ], + "isUnique": false + }, + "sync_tombstone_synced_idx": { + "name": "sync_tombstone_synced_idx", + "columns": [ + "synced" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0d80c3d..be8743c 100755 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1771205359100, "tag": "0025_chunky_silverclaw", "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1771215013379, + "tag": "0026_easy_professor_monster", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3489f5a..f0ea055 100755 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "db:generate": "drizzle-kit generate", "db:migrate:local": "wrangler d1 migrations apply compass-db --local", "db:migrate:prod": "wrangler d1 migrations apply compass-db --remote", + "db:migrate-orgs": "bun run scripts/migrate-to-orgs.ts", + "db:seed-demo": "bun run scripts/seed-demo.ts", "prepare": "husky", "cap:sync": "cap sync", "cap:ios": "cap open ios", diff --git a/scripts/migrate-to-orgs.ts b/scripts/migrate-to-orgs.ts new file mode 100644 index 0000000..c40c15c --- /dev/null +++ b/scripts/migrate-to-orgs.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env bun +/** + * Idempotent migration script to introduce organizations into existing data + * + * This script: + * 1. Creates the HPS organization if it doesn't exist + * 2. Adds all existing users to the HPS organization + * 3. Associates all existing customers, vendors, and projects with HPS + * + * Safe to run multiple times - checks for existing records before inserting. + */ + +import { Database } from "bun:sqlite" +import { resolve, join } from "path" +import { randomUUID } from "crypto" +import { existsSync, readdirSync } from "fs" + +const HPS_ORG_ID = "hps-org-001" +const HPS_ORG_NAME = "HPS" +const HPS_ORG_SLUG = "hps" +const HPS_ORG_TYPE = "internal" + +const DB_DIR = resolve( + process.cwd(), + ".wrangler/state/v3/d1/miniflare-D1DatabaseObject" +) + +function findDatabase(): string { + if (!existsSync(DB_DIR)) { + console.error(`Database directory not found: ${DB_DIR}`) + console.error("Run 'bun dev' at least once to initialize the local D1 database.") + process.exit(1) + } + + const files = readdirSync(DB_DIR) + const sqliteFile = files.find((f: string) => f.endsWith(".sqlite")) + + if (!sqliteFile) { + console.error(`No .sqlite file found in ${DB_DIR}`) + process.exit(1) + } + + return join(DB_DIR, sqliteFile) +} + +function main() { + const dbPath = findDatabase() + console.log(`Using database: ${dbPath}\n`) + + const db = new Database(dbPath) + db.run("PRAGMA journal_mode = WAL") + + const timestamp = new Date().toISOString() + + const tx = db.transaction(() => { + // 1. Create HPS organization + console.log("1. Checking for HPS organization...") + const existingOrg = db + .prepare("SELECT id FROM organizations WHERE id = ?") + .get(HPS_ORG_ID) + + if (existingOrg) { + console.log(` Already exists (${HPS_ORG_ID})`) + } else { + db.prepare( + `INSERT INTO organizations (id, name, slug, type, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, ?, ?)` + ).run(HPS_ORG_ID, HPS_ORG_NAME, HPS_ORG_SLUG, HPS_ORG_TYPE, timestamp, timestamp) + console.log(` Created HPS organization (${HPS_ORG_ID})`) + } + + // 2. Add all users to HPS + console.log("\n2. Adding users to HPS organization...") + const users = db.prepare("SELECT id, role FROM users").all() as Array<{ + id: string + role: string + }> + + let added = 0 + let skipped = 0 + + for (const user of users) { + const existing = db + .prepare( + "SELECT id FROM organization_members WHERE organization_id = ? AND user_id = ?" + ) + .get(HPS_ORG_ID, user.id) + + if (existing) { + skipped++ + } else { + db.prepare( + `INSERT INTO organization_members (id, organization_id, user_id, role, joined_at) + VALUES (?, ?, ?, ?, ?)` + ).run(randomUUID(), HPS_ORG_ID, user.id, user.role, timestamp) + added++ + } + } + + console.log(` Added ${added} user(s), skipped ${skipped}`) + + // 3. Update customers + console.log("\n3. Updating customers...") + const custResult = db + .prepare("UPDATE customers SET organization_id = ? WHERE organization_id IS NULL") + .run(HPS_ORG_ID) + console.log(` Updated ${custResult.changes} customer(s)`) + + // 4. Update vendors + console.log("\n4. Updating vendors...") + const vendResult = db + .prepare("UPDATE vendors SET organization_id = ? WHERE organization_id IS NULL") + .run(HPS_ORG_ID) + console.log(` Updated ${vendResult.changes} vendor(s)`) + + // 5. Update projects + console.log("\n5. Updating projects...") + const projResult = db + .prepare("UPDATE projects SET organization_id = ? WHERE organization_id IS NULL") + .run(HPS_ORG_ID) + console.log(` Updated ${projResult.changes} project(s)`) + + console.log("\nSummary:") + console.log(` Org: ${HPS_ORG_NAME} (${HPS_ORG_ID})`) + console.log(` Members: ${users.length} total (${added} new)`) + console.log(` Customers: ${custResult.changes} updated`) + console.log(` Vendors: ${vendResult.changes} updated`) + console.log(` Projects: ${projResult.changes} updated`) + }) + + try { + tx() + console.log("\nMigration completed successfully.") + } catch (error) { + console.error("\nMigration failed:", error) + process.exit(1) + } finally { + db.close() + } +} + +main() diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts new file mode 100644 index 0000000..bfffd0f --- /dev/null +++ b/scripts/seed-demo.ts @@ -0,0 +1,284 @@ +#!/usr/bin/env bun +/** + * Idempotent seed script for demo organization data. + * + * Creates "Meridian Group" (demo org) with: + * - 3 projects, 5 customers, 5 vendors + * - 23 schedule tasks per project (69 total) + * - 3 channels with 25 messages + * + * Safe to re-run: exits early if demo org already exists. + */ + +import { Database } from "bun:sqlite" +import { resolve, join } from "path" +import { randomUUID } from "crypto" +import { existsSync, readdirSync } from "fs" + +const DEMO_ORG_ID = "demo-org-meridian" +const DEMO_USER_ID = "demo-user-001" + +function findDatabase(): string { + const dbDir = resolve( + process.cwd(), + ".wrangler/state/v3/d1/miniflare-D1DatabaseObject" + ) + + if (!existsSync(dbDir)) { + console.error(`Database directory not found: ${dbDir}`) + console.error("Run 'bun dev' first to create the local database.") + process.exit(1) + } + + const files = readdirSync(dbDir) + const sqliteFile = files.find((f: string) => f.endsWith(".sqlite")) + + if (!sqliteFile) { + console.error(`No .sqlite file found in ${dbDir}`) + process.exit(1) + } + + return join(dbDir, sqliteFile) +} + +function seed(dbPath: string) { + const db = new Database(dbPath) + db.run("PRAGMA journal_mode = WAL") + const now = new Date().toISOString() + + // idempotency check + const existingOrg = db + .prepare("SELECT id FROM organizations WHERE id = ?") + .get(DEMO_ORG_ID) + if (existingOrg) { + console.log("Demo org already exists, skipping seed.") + db.close() + return + } + + console.log("Seeding demo data...\n") + + const tx = db.transaction(() => { + // 1. demo organization + db.prepare( + `INSERT INTO organizations (id, name, slug, type, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, ?, ?)` + ).run(DEMO_ORG_ID, "Meridian Group", "meridian-demo", "demo", now, now) + console.log("1. Created demo organization") + + // 2. demo user + db.prepare( + `INSERT OR IGNORE INTO users (id, email, first_name, last_name, display_name, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)` + ).run(DEMO_USER_ID, "demo@compass.build", "Demo", "User", "Demo User", "admin", now, now) + console.log("2. Created demo user") + + // 3. link user to org + db.prepare( + `INSERT INTO organization_members (id, organization_id, user_id, role, joined_at) + VALUES (?, ?, ?, ?, ?)` + ).run(randomUUID(), DEMO_ORG_ID, DEMO_USER_ID, "admin", now) + console.log("3. Linked demo user to organization") + + // 4. projects + const projects = [ + { id: randomUUID(), name: "Riverside Tower" }, + { id: randomUUID(), name: "Harbor Bridge Rehabilitation" }, + { id: randomUUID(), name: "Downtown Transit Hub" }, + ] + + for (const p of projects) { + db.prepare( + `INSERT INTO projects (id, name, status, organization_id, created_at) + VALUES (?, ?, ?, ?, ?)` + ).run(p.id, p.name, "active", DEMO_ORG_ID, now) + } + console.log("4. Created 3 projects") + + // 5. customers + const customers = [ + "Metropolitan Construction Corp", + "Riverside Development LLC", + "Harbor City Ventures", + "Transit Authority Partners", + "Downtown Realty Group", + ] + for (const name of customers) { + db.prepare( + `INSERT INTO customers (id, name, organization_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ).run(randomUUID(), name, DEMO_ORG_ID, now, now) + } + console.log("5. Created 5 customers") + + // 6. vendors + const vendors = [ + "Ace Steel & Fabrication", + "Premier Concrete Supply", + "ElectroTech Solutions", + "Harbor HVAC Systems", + "Summit Plumbing & Mechanical", + ] + for (const name of vendors) { + db.prepare( + `INSERT INTO vendors (id, name, category, organization_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(randomUUID(), name, "Subcontractor", DEMO_ORG_ID, now, now) + } + console.log("6. Created 5 vendors") + + // 7. schedule tasks per project + const taskTemplates = [ + { title: "Site Preparation & Excavation", workdays: 10, phase: "Foundation", pct: 100 }, + { title: "Foundation Formwork", workdays: 7, phase: "Foundation", pct: 100 }, + { title: "Foundation Concrete Pour", workdays: 3, phase: "Foundation", pct: 100 }, + { title: "Foundation Curing", workdays: 14, phase: "Foundation", pct: 100 }, + { title: "Structural Steel Erection", workdays: 20, phase: "Structural", pct: 85 }, + { title: "Concrete Deck Installation", workdays: 15, phase: "Structural", pct: 70 }, + { title: "Exterior Framing", workdays: 12, phase: "Envelope", pct: 60 }, + { title: "Window & Door Installation", workdays: 10, phase: "Envelope", pct: 40 }, + { title: "Roofing Installation", workdays: 8, phase: "Envelope", pct: 30 }, + { title: "Electrical Rough-In", workdays: 15, phase: "MEP", pct: 25 }, + { title: "Plumbing Rough-In", workdays: 12, phase: "MEP", pct: 20 }, + { title: "HVAC Installation", workdays: 18, phase: "MEP", pct: 15 }, + { title: "Fire Sprinkler System", workdays: 10, phase: "MEP", pct: 10 }, + { title: "Drywall Installation", workdays: 14, phase: "Interior", pct: 5 }, + { title: "Interior Painting", workdays: 10, phase: "Interior", pct: 0 }, + { title: "Flooring Installation", workdays: 12, phase: "Interior", pct: 0 }, + { title: "Cabinet & Fixture Installation", workdays: 8, phase: "Interior", pct: 0 }, + { title: "Final Electrical Trim", workdays: 5, phase: "Finishes", pct: 0 }, + { title: "Final Plumbing Fixtures", workdays: 5, phase: "Finishes", pct: 0 }, + { title: "Site Landscaping", workdays: 10, phase: "Site Work", pct: 0 }, + { title: "Parking Lot Paving", workdays: 7, phase: "Site Work", pct: 0 }, + { title: "Final Inspection", workdays: 2, phase: "Closeout", pct: 0 }, + { title: "Punch List Completion", workdays: 5, phase: "Closeout", pct: 0 }, + ] + + let taskCount = 0 + for (const project of projects) { + let currentDate = new Date("2025-01-15") + + for (const t of taskTemplates) { + const endDate = new Date(currentDate) + endDate.setDate(endDate.getDate() + t.workdays - 1) + + const status = t.pct === 100 ? "COMPLETED" : t.pct > 0 ? "IN_PROGRESS" : "PENDING" + + db.prepare( + `INSERT INTO schedule_tasks + (id, project_id, title, start_date, workdays, end_date_calculated, + phase, status, percent_complete, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + randomUUID(), + project.id, + t.title, + currentDate.toISOString().split("T")[0], + t.workdays, + endDate.toISOString().split("T")[0], + t.phase, + status, + t.pct, + taskCount, + now, + now + ) + + currentDate = new Date(endDate) + currentDate.setDate(currentDate.getDate() + 1) + taskCount++ + } + } + console.log(`7. Created ${taskCount} schedule tasks`) + + // 8. channel category + const categoryId = randomUUID() + db.prepare( + `INSERT INTO channel_categories (id, name, organization_id, position, created_at) + VALUES (?, ?, ?, ?, ?)` + ).run(categoryId, "General", DEMO_ORG_ID, 0, now) + + // 9. channels with messages + const channelDefs = [ + { name: "general", type: "text", desc: "General discussion" }, + { name: "project-updates", type: "text", desc: "Project status updates" }, + { name: "announcements", type: "announcement", desc: "Team announcements" }, + ] + + const msgTemplates: Record = { + general: [ + "Morning team! Ready to tackle today's tasks.", + "Quick reminder: safety meeting at 2pm today.", + "Has anyone seen the updated foundation drawings?", + "Great progress on the structural steel this week!", + "Lunch truck will be here at noon.", + "Weather looks good for concrete pour tomorrow.", + "Don't forget to sign off on your timesheets.", + "Electrical inspection passed with no issues!", + "New delivery of materials arriving Thursday.", + "Team huddle in 10 minutes.", + ], + "project-updates": [ + "Riverside Tower: Foundation phase completed ahead of schedule.", + "Harbor Bridge: Steel erection 85% complete, on track.", + "Transit Hub: Envelope work progressing well despite weather delays.", + "All three projects maintaining budget targets this month.", + "Updated schedules uploaded to the system.", + "Client walkthrough scheduled for Friday morning.", + "MEP rough-in starting next week on Riverside Tower.", + "Harbor Bridge inspection results look excellent.", + "Transit Hub: Window installation begins Monday.", + "Monthly progress photos added to project files.", + ], + announcements: [ + "Welcome to the Compass demo! Explore the platform features.", + "New safety protocols in effect starting next Monday.", + "Quarterly project review meeting scheduled for next Friday.", + "Updated project templates now available in the system.", + "Reminder: Submit all change orders by end of week.", + ], + } + + let msgCount = 0 + for (const ch of channelDefs) { + const channelId = randomUUID() + db.prepare( + `INSERT INTO channels + (id, name, type, description, organization_id, category_id, + is_private, created_by, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)` + ).run(channelId, ch.name, ch.type, ch.desc, DEMO_ORG_ID, categoryId, DEMO_USER_ID, now, now) + + // add member + db.prepare( + `INSERT INTO channel_members (id, channel_id, user_id, role, notify_level, joined_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(randomUUID(), channelId, DEMO_USER_ID, "owner", "all", now) + + // add messages + const msgs = msgTemplates[ch.name] ?? [] + for (const content of msgs) { + db.prepare( + `INSERT INTO messages (id, channel_id, user_id, content, reply_count, created_at) + VALUES (?, ?, ?, ?, 0, ?)` + ).run(randomUUID(), channelId, DEMO_USER_ID, content, now) + msgCount++ + } + } + console.log(`8. Created 3 channels with ${msgCount} messages`) + }) + + try { + tx() + console.log("\nDemo seed completed successfully.") + } catch (error) { + console.error("\nDemo seed failed:", error) + process.exit(1) + } finally { + db.close() + } +} + +const dbPath = findDatabase() +console.log(`Using database: ${dbPath}\n`) +seed(dbPath) diff --git a/src/app/actions/agent.ts b/src/app/actions/agent.ts index 83f0ba3..e8c7d87 100755 --- a/src/app/actions/agent.ts +++ b/src/app/actions/agent.ts @@ -5,6 +5,7 @@ import { getDb } from "@/db" import { agentConversations, agentMemories } from "@/db/schema" import { eq, desc } from "drizzle-orm" import { getCurrentUser } from "@/lib/auth" +import { isDemoUser } from "@/lib/demo" interface SerializedMessage { readonly id: string @@ -23,6 +24,10 @@ export async function saveConversation( const user = await getCurrentUser() if (!user) return { success: false, error: "Unauthorized" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) const now = new Date().toISOString() @@ -181,6 +186,10 @@ export async function deleteConversation( const user = await getCurrentUser() if (!user) return { success: false, error: "Unauthorized" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) diff --git a/src/app/actions/ai-config.ts b/src/app/actions/ai-config.ts index 7cd4cdc..ce2e92a 100755 --- a/src/app/actions/ai-config.ts +++ b/src/app/actions/ai-config.ts @@ -442,21 +442,14 @@ export async function getConversationUsage( const { env } = await getCloudflareContext() const db = getDb(env.DB) - const isAdmin = can(user, "agent", "update") - const rows = await db .select() .from(agentUsage) .where( - isAdmin - ? eq(agentUsage.conversationId, conversationId) - : and( - eq( - agentUsage.conversationId, - conversationId - ), - eq(agentUsage.userId, user.id) - ) + and( + eq(agentUsage.conversationId, conversationId), + eq(agentUsage.userId, user.id) + ) ) .orderBy(desc(agentUsage.createdAt)) .all() diff --git a/src/app/actions/baselines.ts b/src/app/actions/baselines.ts index b214e30..f447286 100755 --- a/src/app/actions/baselines.ts +++ b/src/app/actions/baselines.ts @@ -6,17 +6,35 @@ import { scheduleBaselines, scheduleTasks, taskDependencies, + projects, } from "@/db/schema" -import { eq, asc } from "drizzle-orm" +import { eq, asc, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { requireAuth } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" import type { ScheduleBaselineData } from "@/lib/schedule/types" export async function getBaselines( projectId: string ): Promise { + const user = await requireAuth() + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + throw new Error("Project not found or access denied") + } + return await db .select() .from(scheduleBaselines) @@ -28,9 +46,26 @@ export async function createBaseline( name: string ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + const tasks = await db .select() .from(scheduleTasks) @@ -65,6 +100,12 @@ export async function deleteBaseline( baselineId: string ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -76,6 +117,17 @@ export async function deleteBaseline( if (!existing) return { success: false, error: "Baseline not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, existing.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + await db .delete(scheduleBaselines) .where(eq(scheduleBaselines.id, baselineId)) diff --git a/src/app/actions/channel-categories.ts b/src/app/actions/channel-categories.ts index 31d839b..9b5558b 100644 --- a/src/app/actions/channel-categories.ts +++ b/src/app/actions/channel-categories.ts @@ -7,6 +7,8 @@ import { channelCategories, channels, type NewChannelCategory } from "@/db/schem import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function listCategories() { try { @@ -14,22 +16,11 @@ export async function listCategories() { if (!user) { return { success: false, error: "Unauthorized" } } + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: sql`organization_id` }) - .from(sql`organization_members`) - .where(sql`user_id = ${user.id}`) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - // fetch categories with channel counts const categories = await db .select({ @@ -43,7 +34,7 @@ export async function listCategories() { )`, }) .from(channelCategories) - .where(eq(channelCategories.organizationId, orgMember.organizationId)) + .where(eq(channelCategories.organizationId, orgId)) .orderBy(channelCategories.position) return { success: true, data: categories } @@ -62,31 +53,24 @@ export async function createCategory(name: string, position?: number) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + // admin only requirePermission(user, "channels", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: sql`organization_id` }) - .from(sql`organization_members`) - .where(sql`user_id = ${user.id}`) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - const categoryId = crypto.randomUUID() const now = new Date().toISOString() const newCategory: NewChannelCategory = { id: categoryId, name, - organizationId: orgMember.organizationId, + organizationId: orgId, position: position ?? 0, collapsedByDefault: false, createdAt: now, @@ -114,33 +98,26 @@ export async function updateCategory( return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + // admin only requirePermission(user, "channels", "create") + const orgId = requireOrg(user) - const { env } = await getCloudflareContext() + const { env} = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: sql`organization_id` }) - .from(sql`organization_members`) - .where(sql`user_id = ${user.id}`) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - // verify category exists in user's org const category = await db .select() .from(channelCategories) - .where(eq(channelCategories.id, id)) + .where(and(eq(channelCategories.id, id), eq(channelCategories.organizationId, orgId))) .limit(1) .then((rows) => rows[0] ?? null) - if (!category || category.organizationId !== orgMember.organizationId) { + if (!category) { return { success: false, error: "Category not found" } } @@ -179,33 +156,26 @@ export async function deleteCategory(id: string) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + // admin only requirePermission(user, "channels", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: sql`organization_id` }) - .from(sql`organization_members`) - .where(sql`user_id = ${user.id}`) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - // verify category exists in user's org const category = await db .select() .from(channelCategories) - .where(eq(channelCategories.id, id)) + .where(and(eq(channelCategories.id, id), eq(channelCategories.organizationId, orgId))) .limit(1) .then((rows) => rows[0] ?? null) - if (!category || category.organizationId !== orgMember.organizationId) { + if (!category) { return { success: false, error: "Category not found" } } @@ -247,32 +217,25 @@ export async function reorderChannels( return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + requirePermission(user, "channels", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: sql`organization_id` }) - .from(sql`organization_members`) - .where(sql`user_id = ${user.id}`) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - // verify category exists and belongs to user's org const category = await db .select() .from(channelCategories) - .where(eq(channelCategories.id, categoryId)) + .where(and(eq(channelCategories.id, categoryId), eq(channelCategories.organizationId, orgId))) .limit(1) .then((rows) => rows[0] ?? null) - if (!category || category.organizationId !== orgMember.organizationId) { + if (!category) { return { success: false, error: "Category not found" } } diff --git a/src/app/actions/chat-messages.ts b/src/app/actions/chat-messages.ts index 7d1399c..2027b01 100644 --- a/src/app/actions/chat-messages.ts +++ b/src/app/actions/chat-messages.ts @@ -17,6 +17,8 @@ import { import { users, organizationMembers } from "@/db/schema" import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" +import { isDemoUser } from "@/lib/demo" +import { requireOrg } from "@/lib/org-scope" import { revalidatePath } from "next/cache" const MAX_MESSAGE_LENGTH = 4000 @@ -89,8 +91,7 @@ async function renderMarkdown(content: string): Promise { } export async function searchMentionableUsers( - query: string, - organizationId: string + query: string ) { try { const user = await getCurrentUser() @@ -98,6 +99,8 @@ export async function searchMentionableUsers( return { success: false, error: "Unauthorized" } } + const organizationId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -158,6 +161,9 @@ export async function sendMessage(data: { if (!user) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } if (data.content.length > MAX_MESSAGE_LENGTH) { return { success: false, error: `Message too long (max ${MAX_MESSAGE_LENGTH} characters)` } @@ -315,6 +321,9 @@ export async function editMessage( if (!user) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -393,6 +402,9 @@ export async function deleteMessage(messageId: string) { if (!user) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -621,6 +633,9 @@ export async function addReaction(messageId: string, emoji: string) { if (!user) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) { return { success: false, error: "Invalid emoji" } @@ -705,6 +720,9 @@ export async function removeReaction(messageId: string, emoji: string) { if (!user) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) { return { success: false, error: "Invalid emoji" } diff --git a/src/app/actions/conversations.ts b/src/app/actions/conversations.ts index a7ec954..d24785b 100644 --- a/src/app/actions/conversations.ts +++ b/src/app/actions/conversations.ts @@ -15,6 +15,8 @@ import { users, organizationMembers } from "@/db/schema" import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function listChannels() { try { @@ -22,6 +24,7 @@ export async function listChannels() { if (!user) { return { success: false, error: "Unauthorized" } } + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -64,10 +67,7 @@ export async function listChannels() { .where( and( // must be in user's org - sql`${channels.organizationId} = ( - SELECT organization_id FROM organization_members - WHERE user_id = ${user.id} LIMIT 1 - )`, + eq(channels.organizationId, orgId), // if private, must be a member sql`(${channels.isPrivate} = 0 OR ${channelMembers.userId} IS NOT NULL)`, // not archived @@ -107,6 +107,11 @@ export async function getChannel(channelId: string) { return { success: false, error: "Channel not found" } } + const orgId = requireOrg(user) + if (channel.organizationId !== orgId) { + return { success: false, error: "Channel not found" } + } + // if private, check membership if (channel.isPrivate) { const membership = await db @@ -162,24 +167,17 @@ export async function createChannel(data: { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + // only office+ can create channels requirePermission(user, "channels", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - // get user's organization - const orgMember = await db - .select({ organizationId: organizationMembers.organizationId }) - .from(organizationMembers) - .where(eq(organizationMembers.userId, user.id)) - .limit(1) - .then((rows) => rows[0] ?? null) - - if (!orgMember) { - return { success: false, error: "No organization found" } - } - const now = new Date().toISOString() const channelId = crypto.randomUUID() @@ -188,7 +186,7 @@ export async function createChannel(data: { name: data.name, type: data.type, description: data.description ?? null, - organizationId: orgMember.organizationId, + organizationId: orgId, projectId: data.projectId ?? null, categoryId: data.categoryId ?? null, isPrivate: data.isPrivate ?? false, @@ -242,6 +240,10 @@ export async function joinChannel(channelId: string) { return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -257,6 +259,11 @@ export async function joinChannel(channelId: string) { return { success: false, error: "Channel not found" } } + const orgId = requireOrg(user) + if (channel.organizationId !== orgId) { + return { success: false, error: "Channel not found" } + } + if (channel.isPrivate) { return { success: false, diff --git a/src/app/actions/credit-memos.ts b/src/app/actions/credit-memos.ts index 06c9a68..660267b 100755 --- a/src/app/actions/credit-memos.ts +++ b/src/app/actions/credit-memos.ts @@ -1,34 +1,76 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { creditMemos, type NewCreditMemo } from "@/db/schema-netsuite" -import { getCurrentUser } from "@/lib/auth" +import { projects } from "@/db/schema" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getCreditMemos() { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - return db.select().from(creditMemos) + // join through projects to filter by org + return db + .select({ + id: creditMemos.id, + netsuiteId: creditMemos.netsuiteId, + customerId: creditMemos.customerId, + projectId: creditMemos.projectId, + memoNumber: creditMemos.memoNumber, + status: creditMemos.status, + issueDate: creditMemos.issueDate, + total: creditMemos.total, + amountApplied: creditMemos.amountApplied, + amountRemaining: creditMemos.amountRemaining, + memo: creditMemos.memo, + lineItems: creditMemos.lineItems, + createdAt: creditMemos.createdAt, + updatedAt: creditMemos.updatedAt, + }) + .from(creditMemos) + .innerJoin(projects, eq(creditMemos.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) } export async function getCreditMemo(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // join through project to verify org const rows = await db - .select() + .select({ + id: creditMemos.id, + netsuiteId: creditMemos.netsuiteId, + customerId: creditMemos.customerId, + projectId: creditMemos.projectId, + memoNumber: creditMemos.memoNumber, + status: creditMemos.status, + issueDate: creditMemos.issueDate, + total: creditMemos.total, + amountApplied: creditMemos.amountApplied, + amountRemaining: creditMemos.amountRemaining, + memo: creditMemos.memo, + lineItems: creditMemos.lineItems, + createdAt: creditMemos.createdAt, + updatedAt: creditMemos.updatedAt, + }) .from(creditMemos) - .where(eq(creditMemos.id, id)) + .innerJoin(projects, eq(creditMemos.projectId, projects.id)) + .where(and(eq(creditMemos.id, id), eq(projects.organizationId, orgId))) .limit(1) return rows[0] ?? null @@ -38,12 +80,29 @@ export async function createCreditMemo( data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to org if provided + if (data.projectId) { + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + } + const now = new Date().toISOString() const id = crypto.randomUUID() @@ -72,12 +131,28 @@ export async function updateCreditMemo( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify credit memo belongs to org via project + const [existing] = await db + .select({ projectId: creditMemos.projectId }) + .from(creditMemos) + .innerJoin(projects, eq(creditMemos.projectId, projects.id)) + .where(and(eq(creditMemos.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Credit memo not found or access denied" } + } + await db .update(creditMemos) .set({ ...data, updatedAt: new Date().toISOString() }) @@ -98,12 +173,28 @@ export async function updateCreditMemo( export async function deleteCreditMemo(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify credit memo belongs to org via project + const [existing] = await db + .select({ projectId: creditMemos.projectId }) + .from(creditMemos) + .innerJoin(projects, eq(creditMemos.projectId, projects.id)) + .where(and(eq(creditMemos.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Credit memo not found or access denied" } + } + await db.delete(creditMemos).where(eq(creditMemos.id, id)) revalidatePath("/dashboard/financials") diff --git a/src/app/actions/customers.ts b/src/app/actions/customers.ts index 299ca74..d24a90f 100755 --- a/src/app/actions/customers.ts +++ b/src/app/actions/customers.ts @@ -1,26 +1,30 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { customers, type NewCustomer } from "@/db/schema" -import { getCurrentUser } from "@/lib/auth" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getCustomers() { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "customer", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - return db.select().from(customers) + return db.select().from(customers).where(eq(customers.organizationId, orgId)) } export async function getCustomer(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "customer", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -28,18 +32,22 @@ export async function getCustomer(id: string) { const rows = await db .select() .from(customers) - .where(eq(customers.id, id)) + .where(and(eq(customers.id, id), eq(customers.organizationId, orgId))) .limit(1) return rows[0] ?? null } export async function createCustomer( - data: Omit + data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "customer", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -49,6 +57,7 @@ export async function createCustomer( await db.insert(customers).values({ id, + organizationId: orgId, ...data, createdAt: now, updatedAt: now, @@ -70,8 +79,12 @@ export async function updateCustomer( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "customer", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -79,7 +92,7 @@ export async function updateCustomer( await db .update(customers) .set({ ...data, updatedAt: new Date().toISOString() }) - .where(eq(customers.id, id)) + .where(and(eq(customers.id, id), eq(customers.organizationId, orgId))) revalidatePath("/dashboard/customers") return { success: true } @@ -94,13 +107,19 @@ export async function updateCustomer( export async function deleteCustomer(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "customer", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - await db.delete(customers).where(eq(customers.id, id)) + await db + .delete(customers) + .where(and(eq(customers.id, id), eq(customers.organizationId, orgId))) revalidatePath("/dashboard/customers") return { success: true } diff --git a/src/app/actions/dashboards.ts b/src/app/actions/dashboards.ts index fdfdd56..4e50154 100755 --- a/src/app/actions/dashboards.ts +++ b/src/app/actions/dashboards.ts @@ -1,10 +1,14 @@ "use server" -import { eq, and, desc } from "drizzle-orm" +import { eq, and, desc, inArray } from "drizzle-orm" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { customDashboards } from "@/db/schema-dashboards" +import { customers, vendors, projects, scheduleTasks } from "@/db/schema" +import { invoices, vendorBills } from "@/db/schema-netsuite" import { getCurrentUser } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" import { revalidatePath } from "next/cache" const MAX_DASHBOARDS = 5 @@ -109,6 +113,10 @@ export async function saveCustomDashboard( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -171,6 +179,10 @@ export async function deleteCustomDashboard( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -207,6 +219,8 @@ export async function executeDashboardQueries( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -228,10 +242,15 @@ export async function executeDashboardQueries( limit: cap, ...(q.search ? { - where: (c, { like }) => - like(c.name, `%${q.search}%`), + where: (c, { like, eq, and }) => + and( + eq(c.organizationId, orgId), + like(c.name, `%${q.search}%`), + ), } - : {}), + : { + where: (c, { eq }) => eq(c.organizationId, orgId), + }), }) dataContext[q.key] = { data: rows, count: rows.length } break @@ -241,10 +260,15 @@ export async function executeDashboardQueries( limit: cap, ...(q.search ? { - where: (v, { like }) => - like(v.name, `%${q.search}%`), + where: (v, { like, eq, and }) => + and( + eq(v.organizationId, orgId), + like(v.name, `%${q.search}%`), + ), } - : {}), + : { + where: (v, { eq }) => eq(v.organizationId, orgId), + }), }) dataContext[q.key] = { data: rows, count: rows.length } break @@ -254,38 +278,76 @@ export async function executeDashboardQueries( limit: cap, ...(q.search ? { - where: (p, { like }) => - like(p.name, `%${q.search}%`), + where: (p, { like, eq, and }) => + and( + eq(p.organizationId, orgId), + like(p.name, `%${q.search}%`), + ), } - : {}), + : { + where: (p, { eq }) => eq(p.organizationId, orgId), + }), }) dataContext[q.key] = { data: rows, count: rows.length } break } case "invoices": { - const rows = await db.query.invoices.findMany({ - limit: cap, - }) + const orgProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.organizationId, orgId)) + const projectIds = orgProjects.map((p) => p.id) + const rows = + projectIds.length > 0 + ? await db.query.invoices.findMany({ + limit: cap, + where: (inv, { inArray }) => inArray(inv.projectId, projectIds), + }) + : [] dataContext[q.key] = { data: rows, count: rows.length } break } case "vendor_bills": { - const rows = await db.query.vendorBills.findMany({ - limit: cap, - }) + const orgProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.organizationId, orgId)) + const projectIds = orgProjects.map((p) => p.id) + const rows = + projectIds.length > 0 + ? await db.query.vendorBills.findMany({ + limit: cap, + where: (bill, { inArray }) => + inArray(bill.projectId, projectIds), + }) + : [] dataContext[q.key] = { data: rows, count: rows.length } break } case "schedule_tasks": { - const rows = await db.query.scheduleTasks.findMany({ - limit: cap, - ...(q.search - ? { - where: (t, { like }) => - like(t.title, `%${q.search}%`), - } - : {}), - }) + const orgProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.organizationId, orgId)) + const projectIds = orgProjects.map((p) => p.id) + const rows = + projectIds.length > 0 + ? await db.query.scheduleTasks.findMany({ + limit: cap, + ...(q.search + ? { + where: (t, { like, inArray, and }) => + and( + inArray(t.projectId, projectIds), + like(t.title, `%${q.search}%`), + ), + } + : { + where: (t, { inArray }) => + inArray(t.projectId, projectIds), + }), + }) + : [] dataContext[q.key] = { data: rows, count: rows.length } break } @@ -294,9 +356,13 @@ export async function executeDashboardQueries( const row = await db.query.projects.findFirst({ where: (p, { eq: e }) => e(p.id, q.id!), }) - dataContext[q.key] = row - ? { data: row } - : { error: "not found" } + if (row && row.organizationId !== orgId) { + dataContext[q.key] = { error: "not found" } + } else { + dataContext[q.key] = row + ? { data: row } + : { error: "not found" } + } } break } @@ -305,9 +371,13 @@ export async function executeDashboardQueries( const row = await db.query.customers.findFirst({ where: (c, { eq: e }) => e(c.id, q.id!), }) - dataContext[q.key] = row - ? { data: row } - : { error: "not found" } + if (row && row.organizationId !== orgId) { + dataContext[q.key] = { error: "not found" } + } else { + dataContext[q.key] = row + ? { data: row } + : { error: "not found" } + } } break } @@ -316,9 +386,13 @@ export async function executeDashboardQueries( const row = await db.query.vendors.findFirst({ where: (v, { eq: e }) => e(v.id, q.id!), }) - dataContext[q.key] = row - ? { data: row } - : { error: "not found" } + if (row && row.organizationId !== orgId) { + dataContext[q.key] = { error: "not found" } + } else { + dataContext[q.key] = row + ? { data: row } + : { error: "not found" } + } } break } diff --git a/src/app/actions/groups.ts b/src/app/actions/groups.ts index ea39d90..9ebbb1d 100755 --- a/src/app/actions/groups.ts +++ b/src/app/actions/groups.ts @@ -3,21 +3,27 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { groups, type Group, type NewGroup } from "@/db/schema" -import { getCurrentUser } from "@/lib/auth" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getGroups(): Promise { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() requirePermission(currentUser, "group", "read") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) return [] const db = getDb(env.DB) - const allGroups = await db.select().from(groups) + const allGroups = await db + .select() + .from(groups) + .where(eq(groups.organizationId, orgId)) return allGroups } catch (error) { @@ -27,14 +33,17 @@ export async function getGroups(): Promise { } export async function createGroup( - organizationId: string, name: string, description?: string, color?: string ): Promise<{ success: boolean; error?: string; data?: Group }> { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() + if (isDemoUser(currentUser.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(currentUser, "group", "create") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) { @@ -46,7 +55,7 @@ export async function createGroup( const newGroup: NewGroup = { id: crypto.randomUUID(), - organizationId, + organizationId: orgId, name, description: description ?? null, color: color ?? null, @@ -70,8 +79,12 @@ export async function deleteGroup( groupId: string ): Promise<{ success: boolean; error?: string }> { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() + if (isDemoUser(currentUser.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(currentUser, "group", "delete") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) { @@ -80,7 +93,10 @@ export async function deleteGroup( const db = getDb(env.DB) - await db.delete(groups).where(eq(groups.id, groupId)).run() + await db + .delete(groups) + .where(and(eq(groups.id, groupId), eq(groups.organizationId, orgId))) + .run() revalidatePath("/dashboard/people") return { success: true } diff --git a/src/app/actions/invoices.ts b/src/app/actions/invoices.ts index d87a34d..b752689 100755 --- a/src/app/actions/invoices.ts +++ b/src/app/actions/invoices.ts @@ -1,40 +1,100 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { invoices, type NewInvoice } from "@/db/schema-netsuite" -import { getCurrentUser } from "@/lib/auth" +import { projects } from "@/db/schema" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getInvoices(projectId?: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) if (projectId) { + // verify project belongs to org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + throw new Error("Project not found or access denied") + } + return db .select() .from(invoices) .where(eq(invoices.projectId, projectId)) } - return db.select().from(invoices) + + // join through projects to filter by org + return db + .select({ + id: invoices.id, + netsuiteId: invoices.netsuiteId, + customerId: invoices.customerId, + projectId: invoices.projectId, + invoiceNumber: invoices.invoiceNumber, + status: invoices.status, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + subtotal: invoices.subtotal, + tax: invoices.tax, + total: invoices.total, + amountPaid: invoices.amountPaid, + amountDue: invoices.amountDue, + memo: invoices.memo, + lineItems: invoices.lineItems, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) + .from(invoices) + .innerJoin(projects, eq(invoices.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) } export async function getInvoice(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // join through project to verify org const rows = await db - .select() + .select({ + id: invoices.id, + netsuiteId: invoices.netsuiteId, + customerId: invoices.customerId, + projectId: invoices.projectId, + invoiceNumber: invoices.invoiceNumber, + status: invoices.status, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + subtotal: invoices.subtotal, + tax: invoices.tax, + total: invoices.total, + amountPaid: invoices.amountPaid, + amountDue: invoices.amountDue, + memo: invoices.memo, + lineItems: invoices.lineItems, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) .from(invoices) - .where(eq(invoices.id, id)) + .innerJoin(projects, eq(invoices.projectId, projects.id)) + .where(and(eq(invoices.id, id), eq(projects.organizationId, orgId))) .limit(1) return rows[0] ?? null @@ -44,12 +104,29 @@ export async function createInvoice( data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to org if provided + if (data.projectId) { + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + } + const now = new Date().toISOString() const id = crypto.randomUUID() @@ -75,12 +152,28 @@ export async function updateInvoice( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify invoice belongs to org via project + const [existing] = await db + .select({ projectId: invoices.projectId }) + .from(invoices) + .innerJoin(projects, eq(invoices.projectId, projects.id)) + .where(and(eq(invoices.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Invoice not found or access denied" } + } + await db .update(invoices) .set({ ...data, updatedAt: new Date().toISOString() }) @@ -98,12 +191,28 @@ export async function updateInvoice( export async function deleteInvoice(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify invoice belongs to org via project + const [existing] = await db + .select({ projectId: invoices.projectId }) + .from(invoices) + .innerJoin(projects, eq(invoices.projectId, projects.id)) + .where(and(eq(invoices.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Invoice not found or access denied" } + } + await db.delete(invoices).where(eq(invoices.id, id)) revalidatePath("/dashboard/financials") diff --git a/src/app/actions/mcp-keys.ts b/src/app/actions/mcp-keys.ts index 2a47143..9a3bfd5 100644 --- a/src/app/actions/mcp-keys.ts +++ b/src/app/actions/mcp-keys.ts @@ -10,6 +10,7 @@ import { hashApiKey, } from "@/lib/mcp/auth" import { revalidatePath } from "next/cache" +import { isDemoUser } from "@/lib/demo" export async function createApiKey( name: string, @@ -24,6 +25,10 @@ export async function createApiKey( return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) const now = new Date().toISOString() @@ -129,6 +134,10 @@ export async function revokeApiKey( return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -179,6 +188,10 @@ export async function deleteApiKey( return { success: false, error: "Unauthorized" } } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) diff --git a/src/app/actions/organizations.ts b/src/app/actions/organizations.ts index 78010cb..c1695dc 100755 --- a/src/app/actions/organizations.ts +++ b/src/app/actions/organizations.ts @@ -2,27 +2,42 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" -import { organizations, type Organization, type NewOrganization } from "@/db/schema" +import { organizations, organizationMembers, type Organization, type NewOrganization } from "@/db/schema" import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { cookies } from "next/headers" +import { isDemoUser } from "@/lib/demo" export async function getOrganizations(): Promise { try { const currentUser = await getCurrentUser() + if (!currentUser) return [] requirePermission(currentUser, "organization", "read") const { env } = await getCloudflareContext() if (!env?.DB) return [] const db = getDb(env.DB) - const allOrganizations = await db - .select() + + // filter to orgs the user is a member of + const userOrgs = await db + .select({ + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + type: organizations.type, + logoUrl: organizations.logoUrl, + isActive: organizations.isActive, + createdAt: organizations.createdAt, + updatedAt: organizations.updatedAt, + }) .from(organizations) - .where(eq(organizations.isActive, true)) + .innerJoin(organizationMembers, eq(organizations.id, organizationMembers.organizationId)) + .where(and(eq(organizations.isActive, true), eq(organizationMembers.userId, currentUser.id))) - return allOrganizations + return userOrgs } catch (error) { console.error("Error fetching organizations:", error) return [] @@ -32,10 +47,16 @@ export async function getOrganizations(): Promise { export async function createOrganization( name: string, slug: string, - type: "internal" | "client" + type: "internal" | "client" | "personal" | "demo" ): Promise<{ success: boolean; error?: string; data?: Organization }> { try { const currentUser = await getCurrentUser() + if (!currentUser) { + return { success: false, error: "Unauthorized" } + } + if (isDemoUser(currentUser.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(currentUser, "organization", "create") const { env } = await getCloudflareContext() @@ -80,3 +101,95 @@ export async function createOrganization( } } } + +export async function getUserOrganizations(): Promise< + ReadonlyArray<{ + readonly id: string + readonly name: string + readonly slug: string + readonly type: string + readonly role: string + }> +> { + try { + const currentUser = await getCurrentUser() + if (!currentUser) return [] + + const { env } = await getCloudflareContext() + if (!env?.DB) return [] + + const db = getDb(env.DB) + const results = await db + .select({ + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + type: organizations.type, + role: organizationMembers.role, + }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId) + ) + .where(eq(organizationMembers.userId, currentUser.id)) + + return results + } catch (error) { + console.error("Error fetching user organizations:", error) + return [] + } +} + +export async function switchOrganization( + orgId: string +): Promise<{ success: boolean; error?: string }> { + try { + const currentUser = await getCurrentUser() + if (!currentUser) { + return { success: false, error: "Not authenticated" } + } + + const { env } = await getCloudflareContext() + if (!env?.DB) { + return { success: false, error: "Database not available" } + } + + const db = getDb(env.DB) + + // verify user is member of target org + const membership = await db + .select() + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, orgId), + eq(organizationMembers.userId, currentUser.id) + ) + ) + .get() + + if (!membership) { + return { success: false, error: "Not a member of this organization" } + } + + // set compass-active-org cookie + const cookieStore = await cookies() + cookieStore.set("compass-active-org", orgId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + }) + + revalidatePath("/dashboard") + return { success: true } + } catch (error) { + console.error("Error switching organization:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} diff --git a/src/app/actions/payments.ts b/src/app/actions/payments.ts index 2e5fd98..76370d9 100755 --- a/src/app/actions/payments.ts +++ b/src/app/actions/payments.ts @@ -1,34 +1,74 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { payments, type NewPayment } from "@/db/schema-netsuite" -import { getCurrentUser } from "@/lib/auth" +import { projects } from "@/db/schema" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getPayments() { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - return db.select().from(payments) + // join through projects to filter by org + return db + .select({ + id: payments.id, + netsuiteId: payments.netsuiteId, + customerId: payments.customerId, + vendorId: payments.vendorId, + projectId: payments.projectId, + paymentType: payments.paymentType, + amount: payments.amount, + paymentDate: payments.paymentDate, + paymentMethod: payments.paymentMethod, + referenceNumber: payments.referenceNumber, + memo: payments.memo, + createdAt: payments.createdAt, + updatedAt: payments.updatedAt, + }) + .from(payments) + .innerJoin(projects, eq(payments.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) } export async function getPayment(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // join through project to verify org const rows = await db - .select() + .select({ + id: payments.id, + netsuiteId: payments.netsuiteId, + customerId: payments.customerId, + vendorId: payments.vendorId, + projectId: payments.projectId, + paymentType: payments.paymentType, + amount: payments.amount, + paymentDate: payments.paymentDate, + paymentMethod: payments.paymentMethod, + referenceNumber: payments.referenceNumber, + memo: payments.memo, + createdAt: payments.createdAt, + updatedAt: payments.updatedAt, + }) .from(payments) - .where(eq(payments.id, id)) + .innerJoin(projects, eq(payments.projectId, projects.id)) + .where(and(eq(payments.id, id), eq(projects.organizationId, orgId))) .limit(1) return rows[0] ?? null @@ -38,12 +78,29 @@ export async function createPayment( data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to org if provided + if (data.projectId) { + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + } + const now = new Date().toISOString() const id = crypto.randomUUID() @@ -70,12 +127,28 @@ export async function updatePayment( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify payment belongs to org via project + const [existing] = await db + .select({ projectId: payments.projectId }) + .from(payments) + .innerJoin(projects, eq(payments.projectId, projects.id)) + .where(and(eq(payments.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Payment not found or access denied" } + } + await db .update(payments) .set({ ...data, updatedAt: new Date().toISOString() }) @@ -94,12 +167,28 @@ export async function updatePayment( export async function deletePayment(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify payment belongs to org via project + const [existing] = await db + .select({ projectId: payments.projectId }) + .from(payments) + .innerJoin(projects, eq(payments.projectId, projects.id)) + .where(and(eq(payments.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Payment not found or access denied" } + } + await db.delete(payments).where(eq(payments.id, id)) revalidatePath("/dashboard/financials") diff --git a/src/app/actions/plugins.ts b/src/app/actions/plugins.ts index 629af66..b1d0f69 100755 --- a/src/app/actions/plugins.ts +++ b/src/app/actions/plugins.ts @@ -11,6 +11,7 @@ import { import { getCurrentUser } from "@/lib/auth" import { fetchSkillFromGitHub } from "@/lib/agent/plugins/skills-client" import { clearRegistryCache } from "@/lib/agent/plugins/registry" +import { isDemoUser } from "@/lib/demo" function skillId(source: string): string { return "skill-" + source.replace(/\//g, "-").toLowerCase() @@ -23,6 +24,10 @@ export async function installSkill(source: string): Promise< const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -103,6 +108,10 @@ export async function uninstallSkill(pluginId: string): Promise< const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -137,6 +146,10 @@ export async function toggleSkill( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) const now = new Date().toISOString() diff --git a/src/app/actions/projects.ts b/src/app/actions/projects.ts index b3cbb27..662f9b4 100755 --- a/src/app/actions/projects.ts +++ b/src/app/actions/projects.ts @@ -3,10 +3,15 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { projects } from "@/db/schema" -import { asc } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" +import { requireAuth } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" export async function getProjects(): Promise<{ id: string; name: string }[]> { try { + const user = await requireAuth() + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() if (!env?.DB) return [] @@ -14,6 +19,7 @@ export async function getProjects(): Promise<{ id: string; name: string }[]> { const allProjects = await db .select({ id: projects.id, name: projects.name }) .from(projects) + .where(eq(projects.organizationId, orgId)) .orderBy(asc(projects.name)) return allProjects diff --git a/src/app/actions/schedule.ts b/src/app/actions/schedule.ts index 4f8fc3f..fc5457a 100755 --- a/src/app/actions/schedule.ts +++ b/src/app/actions/schedule.ts @@ -8,12 +8,15 @@ import { workdayExceptions, projects, } from "@/db/schema" -import { eq, asc } from "drizzle-orm" +import { eq, asc, and } from "drizzle-orm" import { revalidatePath } from "next/cache" import { calculateEndDate } from "@/lib/schedule/business-days" import { findCriticalPath } from "@/lib/schedule/critical-path" import { wouldCreateCycle } from "@/lib/schedule/dependency-validation" import { propagateDates } from "@/lib/schedule/propagate-dates" +import { requireAuth } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" import type { TaskStatus, DependencyType, @@ -42,9 +45,23 @@ async function fetchExceptions( export async function getSchedule( projectId: string ): Promise { + const user = await requireAuth() + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + throw new Error("Project not found or access denied") + } + const tasks = await db .select() .from(scheduleTasks) @@ -86,9 +103,26 @@ export async function createTask( } ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + const exceptions = await fetchExceptions(db, projectId) const endDate = calculateEndDate( data.startDate, data.workdays, exceptions @@ -146,6 +180,12 @@ export async function updateTask( } ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -157,6 +197,17 @@ export async function updateTask( if (!task) return { success: false, error: "Task not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, task.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + const exceptions = await fetchExceptions(db, task.projectId) const startDate = data.startDate ?? task.startDate const workdays = data.workdays ?? task.workdays @@ -223,6 +274,12 @@ export async function deleteTask( taskId: string ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -234,6 +291,17 @@ export async function deleteTask( if (!task) return { success: false, error: "Task not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, task.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + await db.delete(scheduleTasks).where(eq(scheduleTasks.id, taskId)) await recalcCriticalPath(db, task.projectId) revalidatePath(`/dashboard/projects/${task.projectId}/schedule`) @@ -249,9 +317,26 @@ export async function reorderTasks( items: { id: string; sortOrder: number }[] ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + for (const item of items) { await db .update(scheduleTasks) @@ -275,9 +360,26 @@ export async function createDependency(data: { projectId: string }): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + // get existing deps for cycle check const schedule = await getSchedule(data.projectId) @@ -327,9 +429,26 @@ export async function deleteDependency( projectId: string ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + await db.delete(taskDependencies).where(eq(taskDependencies.id, depId)) await recalcCriticalPath(db, projectId) revalidatePath(`/dashboard/projects/${projectId}/schedule`) @@ -345,6 +464,12 @@ export async function updateTaskStatus( status: TaskStatus ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -356,6 +481,17 @@ export async function updateTaskStatus( if (!task) return { success: false, error: "Task not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, task.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + await db .update(scheduleTasks) .set({ status, updatedAt: new Date().toISOString() }) diff --git a/src/app/actions/teams.ts b/src/app/actions/teams.ts index e2ffb28..5ee871b 100755 --- a/src/app/actions/teams.ts +++ b/src/app/actions/teams.ts @@ -3,21 +3,27 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { teams, type Team, type NewTeam } from "@/db/schema" -import { getCurrentUser } from "@/lib/auth" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getTeams(): Promise { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() requirePermission(currentUser, "team", "read") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) return [] const db = getDb(env.DB) - const allTeams = await db.select().from(teams) + const allTeams = await db + .select() + .from(teams) + .where(eq(teams.organizationId, orgId)) return allTeams } catch (error) { @@ -27,13 +33,16 @@ export async function getTeams(): Promise { } export async function createTeam( - organizationId: string, name: string, description?: string ): Promise<{ success: boolean; error?: string; data?: Team }> { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() + if (isDemoUser(currentUser.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(currentUser, "team", "create") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) { @@ -45,7 +54,7 @@ export async function createTeam( const newTeam: NewTeam = { id: crypto.randomUUID(), - organizationId, + organizationId: orgId, name, description: description ?? null, createdAt: now, @@ -68,8 +77,12 @@ export async function deleteTeam( teamId: string ): Promise<{ success: boolean; error?: string }> { try { - const currentUser = await getCurrentUser() + const currentUser = await requireAuth() + if (isDemoUser(currentUser.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(currentUser, "team", "delete") + const orgId = requireOrg(currentUser) const { env } = await getCloudflareContext() if (!env?.DB) { @@ -78,7 +91,10 @@ export async function deleteTeam( const db = getDb(env.DB) - await db.delete(teams).where(eq(teams.id, teamId)).run() + await db + .delete(teams) + .where(and(eq(teams.id, teamId), eq(teams.organizationId, orgId))) + .run() revalidatePath("/dashboard/people") return { success: true } diff --git a/src/app/actions/themes.ts b/src/app/actions/themes.ts index 607e2fa..9cc0bb7 100755 --- a/src/app/actions/themes.ts +++ b/src/app/actions/themes.ts @@ -10,6 +10,7 @@ import { import { getCurrentUser } from "@/lib/auth" import { findPreset } from "@/lib/theme/presets" import { revalidatePath } from "next/cache" +import { isDemoUser } from "@/lib/demo" export async function getUserThemePreference(): Promise< | { readonly success: true; readonly data: { readonly activeThemeId: string } } @@ -148,6 +149,10 @@ export async function saveCustomTheme( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -191,6 +196,10 @@ export async function deleteCustomTheme( const user = await getCurrentUser() if (!user) return { success: false, error: "not authenticated" } + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const { env } = await getCloudflareContext() const db = getDb(env.DB) diff --git a/src/app/actions/vendor-bills.ts b/src/app/actions/vendor-bills.ts index e7d3512..3047145 100755 --- a/src/app/actions/vendor-bills.ts +++ b/src/app/actions/vendor-bills.ts @@ -1,40 +1,100 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { vendorBills, type NewVendorBill } from "@/db/schema-netsuite" -import { getCurrentUser } from "@/lib/auth" +import { projects } from "@/db/schema" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getVendorBills(projectId?: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) if (projectId) { + // verify project belongs to org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + throw new Error("Project not found or access denied") + } + return db .select() .from(vendorBills) .where(eq(vendorBills.projectId, projectId)) } - return db.select().from(vendorBills) + + // join through projects to filter by org + return db + .select({ + id: vendorBills.id, + netsuiteId: vendorBills.netsuiteId, + vendorId: vendorBills.vendorId, + projectId: vendorBills.projectId, + billNumber: vendorBills.billNumber, + status: vendorBills.status, + billDate: vendorBills.billDate, + dueDate: vendorBills.dueDate, + subtotal: vendorBills.subtotal, + tax: vendorBills.tax, + total: vendorBills.total, + amountPaid: vendorBills.amountPaid, + amountDue: vendorBills.amountDue, + memo: vendorBills.memo, + lineItems: vendorBills.lineItems, + createdAt: vendorBills.createdAt, + updatedAt: vendorBills.updatedAt, + }) + .from(vendorBills) + .innerJoin(projects, eq(vendorBills.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) } export async function getVendorBill(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "finance", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // join through project to verify org const rows = await db - .select() + .select({ + id: vendorBills.id, + netsuiteId: vendorBills.netsuiteId, + vendorId: vendorBills.vendorId, + projectId: vendorBills.projectId, + billNumber: vendorBills.billNumber, + status: vendorBills.status, + billDate: vendorBills.billDate, + dueDate: vendorBills.dueDate, + subtotal: vendorBills.subtotal, + tax: vendorBills.tax, + total: vendorBills.total, + amountPaid: vendorBills.amountPaid, + amountDue: vendorBills.amountDue, + memo: vendorBills.memo, + lineItems: vendorBills.lineItems, + createdAt: vendorBills.createdAt, + updatedAt: vendorBills.updatedAt, + }) .from(vendorBills) - .where(eq(vendorBills.id, id)) + .innerJoin(projects, eq(vendorBills.projectId, projects.id)) + .where(and(eq(vendorBills.id, id), eq(projects.organizationId, orgId))) .limit(1) return rows[0] ?? null @@ -44,12 +104,29 @@ export async function createVendorBill( data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to org if provided + if (data.projectId) { + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + } + const now = new Date().toISOString() const id = crypto.randomUUID() @@ -75,12 +152,28 @@ export async function updateVendorBill( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify bill belongs to org via project + const [existing] = await db + .select({ projectId: vendorBills.projectId }) + .from(vendorBills) + .innerJoin(projects, eq(vendorBills.projectId, projects.id)) + .where(and(eq(vendorBills.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Bill not found or access denied" } + } + await db .update(vendorBills) .set({ ...data, updatedAt: new Date().toISOString() }) @@ -98,12 +191,28 @@ export async function updateVendorBill( export async function deleteVendorBill(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "finance", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify bill belongs to org via project + const [existing] = await db + .select({ projectId: vendorBills.projectId }) + .from(vendorBills) + .innerJoin(projects, eq(vendorBills.projectId, projects.id)) + .where(and(eq(vendorBills.id, id), eq(projects.organizationId, orgId))) + .limit(1) + + if (!existing) { + return { success: false, error: "Bill not found or access denied" } + } + await db.delete(vendorBills).where(eq(vendorBills.id, id)) revalidatePath("/dashboard/financials") diff --git a/src/app/actions/vendors.ts b/src/app/actions/vendors.ts index 5db2346..d2e27a4 100755 --- a/src/app/actions/vendors.ts +++ b/src/app/actions/vendors.ts @@ -1,26 +1,30 @@ "use server" import { getCloudflareContext } from "@opennextjs/cloudflare" -import { eq } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { getDb } from "@/db" import { vendors, type NewVendor } from "@/db/schema" -import { getCurrentUser } from "@/lib/auth" +import { requireAuth } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { revalidatePath } from "next/cache" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" export async function getVendors() { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "vendor", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - return db.select().from(vendors) + return db.select().from(vendors).where(eq(vendors.organizationId, orgId)) } export async function getVendor(id: string) { - const user = await getCurrentUser() + const user = await requireAuth() requirePermission(user, "vendor", "read") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -28,18 +32,22 @@ export async function getVendor(id: string) { const rows = await db .select() .from(vendors) - .where(eq(vendors.id, id)) + .where(and(eq(vendors.id, id), eq(vendors.organizationId, orgId))) .limit(1) return rows[0] ?? null } export async function createVendor( - data: Omit + data: Omit ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "vendor", "create") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -49,6 +57,7 @@ export async function createVendor( await db.insert(vendors).values({ id, + organizationId: orgId, ...data, createdAt: now, updatedAt: now, @@ -70,8 +79,12 @@ export async function updateVendor( data: Partial ) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "vendor", "update") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -79,7 +92,7 @@ export async function updateVendor( await db .update(vendors) .set({ ...data, updatedAt: new Date().toISOString() }) - .where(eq(vendors.id, id)) + .where(and(eq(vendors.id, id), eq(vendors.organizationId, orgId))) revalidatePath("/dashboard/vendors") return { success: true } @@ -94,13 +107,19 @@ export async function updateVendor( export async function deleteVendor(id: string) { try { - const user = await getCurrentUser() + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } requirePermission(user, "vendor", "delete") + const orgId = requireOrg(user) const { env } = await getCloudflareContext() const db = getDb(env.DB) - await db.delete(vendors).where(eq(vendors.id, id)) + await db + .delete(vendors) + .where(and(eq(vendors.id, id), eq(vendors.organizationId, orgId))) revalidatePath("/dashboard/vendors") return { success: true } diff --git a/src/app/actions/workday-exceptions.ts b/src/app/actions/workday-exceptions.ts index 4b8d766..99a321c 100755 --- a/src/app/actions/workday-exceptions.ts +++ b/src/app/actions/workday-exceptions.ts @@ -2,9 +2,12 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" -import { workdayExceptions } from "@/db/schema" -import { eq } from "drizzle-orm" +import { workdayExceptions, projects } from "@/db/schema" +import { eq, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { requireAuth } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" +import { isDemoUser } from "@/lib/demo" import type { WorkdayExceptionData, ExceptionCategory, @@ -14,9 +17,23 @@ import type { export async function getWorkdayExceptions( projectId: string ): Promise { + const user = await requireAuth() + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + throw new Error("Project not found or access denied") + } + const rows = await db .select() .from(workdayExceptions) @@ -42,8 +59,26 @@ export async function createWorkdayException( } ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) + + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Project not found or access denied" } + } + const now = new Date().toISOString() await db.insert(workdayExceptions).values({ @@ -81,6 +116,12 @@ export async function updateWorkdayException( } ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -92,6 +133,17 @@ export async function updateWorkdayException( if (!existing) return { success: false, error: "Exception not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, existing.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + await db .update(workdayExceptions) .set({ @@ -120,6 +172,12 @@ export async function deleteWorkdayException( exceptionId: string ): Promise<{ success: boolean; error?: string }> { try { + const user = await requireAuth() + if (isDemoUser(user.id)) { + return { success: false, error: "DEMO_READ_ONLY" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -131,6 +189,17 @@ export async function deleteWorkdayException( if (!existing) return { success: false, error: "Exception not found" } + // verify project belongs to user's org + const [project] = await db + .select() + .from(projects) + .where(and(eq(projects.id, existing.projectId), eq(projects.organizationId, orgId))) + .limit(1) + + if (!project) { + return { success: false, error: "Access denied" } + } + await db .delete(workdayExceptions) .where(eq(workdayExceptions.id, exceptionId)) diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index c1ae7ca..25bcde6 100755 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -20,6 +20,7 @@ import { getRegistry } from "@/lib/agent/plugins/registry" import { saveStreamUsage } from "@/lib/agent/usage" import { getCurrentUser } from "@/lib/auth" import { getDb } from "@/db" +import { isDemoUser } from "@/lib/demo" export async function POST(req: Request): Promise { const user = await getCurrentUser() @@ -87,6 +88,9 @@ export async function POST(req: Request): Promise { const model = createModelFromId(apiKey, modelId) + // detect demo mode + const isDemo = isDemoUser(user.id) + const result = streamText({ model, system: buildSystemPrompt({ @@ -99,7 +103,7 @@ export async function POST(req: Request): Promise { dashboards: dashboardResult.success ? dashboardResult.data : [], - mode: "full", + mode: isDemo ? "demo" : "full", }), messages: await convertToModelMessages( body.messages diff --git a/src/app/api/sync/mutate/route.ts b/src/app/api/sync/mutate/route.ts index c822da3..b465d1e 100644 --- a/src/app/api/sync/mutate/route.ts +++ b/src/app/api/sync/mutate/route.ts @@ -290,11 +290,19 @@ async function checkResourceAuthorization( // Get user for role-based permission check const userRecords = await db.select().from(users).where(eq(users.id, userId)).limit(1) - const user = userRecords[0] - if (!user) { + const dbUser = userRecords[0] + if (!dbUser) { return { authorized: false, reason: "User not found" } } + // Convert DB user to AuthUser for permission check (org fields not used by can()) + const user = { + ...dbUser, + organizationId: null, + organizationName: null, + organizationType: null, + } + const action = operationToAction(operation) // Check role-based permission diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index b8e66a7..445f358 100755 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -27,6 +27,8 @@ import { PushNotificationRegistrar } from "@/hooks/use-native-push" import { DesktopShell } from "@/components/desktop/desktop-shell" import { DesktopOfflineBanner } from "@/components/desktop/offline-banner" import { VoiceProvider } from "@/components/voice/voice-provider" +import { DemoBanner } from "@/components/demo/demo-banner" +import { isDemoUser } from "@/lib/demo" export default async function DashboardLayout({ children, @@ -40,9 +42,12 @@ export default async function DashboardLayout({ getCustomDashboards(), ]) const user = authUser ? toSidebarUser(authUser) : null + const activeOrgId = authUser?.organizationId ?? null + const activeOrgName = authUser?.organizationName ?? null const dashboardList = dashboardResult.success ? dashboardResult.data : [] + const isDemo = authUser ? isDemoUser(authUser.id) : false return ( @@ -51,7 +56,7 @@ export default async function DashboardLayout({ - + @@ -64,10 +69,18 @@ export default async function DashboardLayout({ } as React.CSSProperties } > - + +
diff --git a/src/app/demo/route.ts b/src/app/demo/route.ts new file mode 100644 index 0000000..4222112 --- /dev/null +++ b/src/app/demo/route.ts @@ -0,0 +1,14 @@ +import { cookies } from "next/headers" +import { redirect } from "next/navigation" + +export async function GET() { + const cookieStore = await cookies() + cookieStore.set("compass-demo", "true", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24, // 24 hours + }) + redirect("/dashboard") +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6cc7471..56109c3 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -426,7 +426,7 @@ export default function Home(): React.JSX.Element { style={{ animationDelay: "0.65s" }} > & { readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }> readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }> readonly user: SidebarUser | null + readonly activeOrgId?: string | null + readonly activeOrgName?: string | null }) { const { isMobile } = useSidebar() const { channelId } = useVoiceState() @@ -181,6 +186,7 @@ export function AppSidebar({ + ({ id: user.id, diff --git a/src/components/demo/demo-banner.tsx b/src/components/demo/demo-banner.tsx new file mode 100644 index 0000000..da7b9a0 --- /dev/null +++ b/src/components/demo/demo-banner.tsx @@ -0,0 +1,45 @@ +"use client" + +import Link from "next/link" +import { X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useState } from "react" +import { cn } from "@/lib/utils" + +interface DemoBannerProps { + readonly isDemo: boolean +} + +export function DemoBanner({ isDemo }: DemoBannerProps) { + const [dismissed, setDismissed] = useState(false) + + if (!isDemo || dismissed) return null + + return ( +
+ + You’re exploring a demo workspace + +
+ + +
+ +
+ ) +} diff --git a/src/components/demo/demo-cta-dialog.tsx b/src/components/demo/demo-cta-dialog.tsx new file mode 100644 index 0000000..2619fd8 --- /dev/null +++ b/src/components/demo/demo-cta-dialog.tsx @@ -0,0 +1,40 @@ +"use client" + +import Link from "next/link" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +interface DemoCtaDialogProps { + readonly open: boolean + readonly onOpenChange: (open: boolean) => void +} + +export function DemoCtaDialog({ open, onOpenChange }: DemoCtaDialogProps) { + return ( + + + + Ready to build your own workspace? + + Sign up to create projects, manage schedules, and collaborate with + your team. + + +
+ + +
+
+
+ ) +} diff --git a/src/components/demo/demo-gate.tsx b/src/components/demo/demo-gate.tsx new file mode 100644 index 0000000..4b0b7bc --- /dev/null +++ b/src/components/demo/demo-gate.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useState } from "react" +import { DemoCtaDialog } from "./demo-cta-dialog" + +export function DemoGate({ + children, + isDemo, +}: { + readonly children: React.ReactNode + readonly isDemo: boolean +}) { + const [showCta, setShowCta] = useState(false) + + if (!isDemo) return <>{children} + + return ( + <> +
{ + e.preventDefault() + e.stopPropagation() + setShowCta(true) + }} + > + {children} +
+ + + ) +} diff --git a/src/components/native/biometric-guard.tsx b/src/components/native/biometric-guard.tsx index 92abe0c..eeb5384 100755 --- a/src/components/native/biometric-guard.tsx +++ b/src/components/native/biometric-guard.tsx @@ -5,13 +5,16 @@ import { useNative } from "@/hooks/use-native" import { useBiometricAuth } from "@/hooks/use-biometric-auth" import { Fingerprint, KeyRound } from "lucide-react" import { Button } from "@/components/ui/button" +import { isDemoUser } from "@/lib/demo" const BACKGROUND_THRESHOLD_MS = 30_000 export function BiometricGuard({ children, + userId, }: { readonly children: React.ReactNode + readonly userId?: string }) { const native = useNative() const { @@ -23,6 +26,9 @@ export function BiometricGuard({ markPrompted, } = useBiometricAuth() + // skip biometric for demo users + const isDemo = userId ? isDemoUser(userId) : false + const [locked, setLocked] = useState(false) const [showPrompt, setShowPrompt] = useState(false) const backgroundedAt = useRef(null) @@ -104,7 +110,7 @@ export function BiometricGuard({ if (success) setLocked(false) }, [authenticate]) - if (!native) return <>{children} + if (!native || isDemo) return <>{children} return ( <> diff --git a/src/components/org-switcher.tsx b/src/components/org-switcher.tsx new file mode 100644 index 0000000..bc0d891 --- /dev/null +++ b/src/components/org-switcher.tsx @@ -0,0 +1,161 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { + IconBuilding, + IconCheck, + IconChevronDown, + IconUser, +} from "@tabler/icons-react" + +import { getUserOrganizations, switchOrganization } from "@/app/actions/organizations" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +type OrgInfo = { + readonly id: string + readonly name: string + readonly slug: string + readonly type: string + readonly role: string +} + +export function OrgSwitcher({ + activeOrgId, + activeOrgName, +}: { + readonly activeOrgId: string | null + readonly activeOrgName: string | null +}): React.ReactElement | null { + const router = useRouter() + const { isMobile } = useSidebar() + const [orgs, setOrgs] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadOrgs(): Promise { + const result = await getUserOrganizations() + setOrgs(result) + } + void loadOrgs() + }, []) + + async function handleOrgSwitch(orgId: string): Promise { + if (orgId === activeOrgId) return + + setIsLoading(true) + const result = await switchOrganization(orgId) + + if (result.success) { + router.refresh() + } else { + console.error("Failed to switch organization:", result.error) + setIsLoading(false) + } + } + + if (!activeOrgId || !activeOrgName) { + return null + } + + const activeOrg = orgs.find((org) => org.id === activeOrgId) + const orgInitial = activeOrgName[0]?.toUpperCase() ?? "O" + + return ( + + + + + +
+ {activeOrg?.type === "personal" ? ( + + ) : ( + + )} +
+
+ + {activeOrgName} + + {activeOrg && ( + + {activeOrg.role} + + )} +
+ +
+
+ + + Organizations + + + {orgs.map((org) => { + const isActive = org.id === activeOrgId + const orgIcon = + org.type === "personal" ? IconUser : IconBuilding + + return ( + void handleOrgSwitch(org.id)} + disabled={isLoading} + className={cn( + "flex items-center gap-2 px-2 py-1.5", + isActive && "bg-accent" + )} + > +
+ {React.createElement(orgIcon, { className: "size-3" })} +
+
+ + {org.name} + + + {org.role} + +
+ + {org.type} + + {isActive && ( + + )} +
+ ) + })} +
+
+
+
+ ) +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 9f8d6f5..91c78bb 100755 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -182,6 +182,7 @@ export const customers = sqliteTable("customers", { address: text("address"), notes: text("notes"), netsuiteId: text("netsuite_id"), + organizationId: text("organization_id").references(() => organizations.id), createdAt: text("created_at").notNull(), updatedAt: text("updated_at"), }) @@ -194,6 +195,7 @@ export const vendors = sqliteTable("vendors", { phone: text("phone"), address: text("address"), netsuiteId: text("netsuite_id"), + organizationId: text("organization_id").references(() => organizations.id), createdAt: text("created_at").notNull(), updatedAt: text("updated_at"), }) diff --git a/src/lib/agent/system-prompt.ts b/src/lib/agent/system-prompt.ts index 34811ef..5b8a3e6 100755 --- a/src/lib/agent/system-prompt.ts +++ b/src/lib/agent/system-prompt.ts @@ -3,7 +3,7 @@ import type { PromptSection } from "@/lib/agent/plugins/types" // --- types --- -type PromptMode = "full" | "minimal" | "none" +type PromptMode = "full" | "minimal" | "none" | "demo" interface DashboardSummary { readonly id: string @@ -231,6 +231,13 @@ const MINIMAL_CATEGORIES: ReadonlySet = new Set([ "ui", ]) +// categories included in demo mode (read-only subset) +const DEMO_CATEGORIES: ReadonlySet = new Set([ + "data", + "navigation", + "ui", +]) + // --- derived state --- function extractDescription( @@ -268,6 +275,9 @@ function computeDerivedState(ctx: PromptContext): DerivedState { if (mode === "minimal") { return MINIMAL_CATEGORIES.has(t.category) } + if (mode === "demo") { + return DEMO_CATEGORIES.has(t.category) + } return true }) @@ -281,6 +291,15 @@ function buildIdentity(mode: PromptMode): ReadonlyArray { "You are Dr. Slab Diggems, the AI assistant built " + "into Compass — a construction project management platform." if (mode === "none") return [line] + if (mode === "demo") { + return [ + line + + " You are reliable, direct, and always ready to help. " + + "You're currently showing a demo workspace to a prospective user — " + + "be enthusiastic about Compass features and suggest they sign up " + + "when they try to perform mutations.", + ] + } return [line + " You are reliable, direct, and always ready to help."] } @@ -661,6 +680,21 @@ function buildGuidelines( if (mode === "minimal") return core + if (mode === "demo") { + return [ + ...core, + "- Demo mode: you can show data and navigate, but when the " + + "user tries to create, edit, or delete records, gently " + + 'suggest they sign up. For example: "To create your own ' + + 'projects and data, sign up for a free account!"', + "- Be enthusiastic about Compass features. Show off what the " + + "platform can do.", + "- The demo data includes 3 sample projects, customers, " + + "vendors, invoices, and team channels. Use these to " + + "demonstrate capabilities.", + ] + } + return [ ...core, "- Tool workflow: data requests -> queryData immediately. " + diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index d63866d..33e7e77 100755 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { getCurrentUser } from "@/lib/auth" +import { requireOrg } from "@/lib/org-scope" import { saveMemory, searchMemories } from "@/lib/agent/memory" import { installSkill as installSkillAction, @@ -23,6 +24,9 @@ import { } from "@/app/actions/dashboards" import { THEME_PRESETS, findPreset } from "@/lib/theme/presets" import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types" +import { projects, scheduleTasks } from "@/db/schema" +import { invoices, vendorBills } from "@/db/schema-netsuite" +import { eq, and, like } from "drizzle-orm" const queryDataInputSchema = z.object({ queryType: z.enum([ @@ -151,6 +155,12 @@ const recallInputSchema = z.object({ type RecallInput = z.infer async function executeQueryData(input: QueryDataInput) { + const user = await getCurrentUser() + if (!user?.organizationId) { + return { error: "no organization context" } + } + const orgId = requireOrg(user) + const { env } = await getCloudflareContext() const db = getDb(env.DB) const cap = input.limit ?? 20 @@ -159,12 +169,13 @@ async function executeQueryData(input: QueryDataInput) { case "customers": { const rows = await db.query.customers.findMany({ limit: cap, - ...(input.search - ? { - where: (c, { like }) => - like(c.name, `%${input.search}%`), - } - : {}), + where: (c, { eq: eqFunc, like: likeFunc, and: andFunc }) => { + const conditions = [eqFunc(c.organizationId, orgId)] + if (input.search) { + conditions.push(likeFunc(c.name, `%${input.search}%`)) + } + return conditions.length > 1 ? andFunc(...conditions) : conditions[0] + }, }) return { data: rows, count: rows.length } } @@ -172,12 +183,13 @@ async function executeQueryData(input: QueryDataInput) { case "vendors": { const rows = await db.query.vendors.findMany({ limit: cap, - ...(input.search - ? { - where: (v, { like }) => - like(v.name, `%${input.search}%`), - } - : {}), + where: (v, { eq: eqFunc, like: likeFunc, and: andFunc }) => { + const conditions = [eqFunc(v.organizationId, orgId)] + if (input.search) { + conditions.push(likeFunc(v.name, `%${input.search}%`)) + } + return conditions.length > 1 ? andFunc(...conditions) : conditions[0] + }, }) return { data: rows, count: rows.length } } @@ -185,40 +197,103 @@ async function executeQueryData(input: QueryDataInput) { case "projects": { const rows = await db.query.projects.findMany({ limit: cap, - ...(input.search - ? { - where: (p, { like }) => - like(p.name, `%${input.search}%`), - } - : {}), + where: (p, { eq: eqFunc, like: likeFunc, and: andFunc }) => { + const conditions = [eqFunc(p.organizationId, orgId)] + if (input.search) { + conditions.push(likeFunc(p.name, `%${input.search}%`)) + } + return conditions.length > 1 ? andFunc(...conditions) : conditions[0] + }, }) return { data: rows, count: rows.length } } case "invoices": { - const rows = await db.query.invoices.findMany({ - limit: cap, - }) + // join through projects to filter by org + const rows = await db + .select({ + id: invoices.id, + netsuiteId: invoices.netsuiteId, + customerId: invoices.customerId, + projectId: invoices.projectId, + invoiceNumber: invoices.invoiceNumber, + status: invoices.status, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + subtotal: invoices.subtotal, + tax: invoices.tax, + total: invoices.total, + amountPaid: invoices.amountPaid, + amountDue: invoices.amountDue, + memo: invoices.memo, + lineItems: invoices.lineItems, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) + .from(invoices) + .innerJoin(projects, eq(invoices.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) + .limit(cap) return { data: rows, count: rows.length } } case "vendor_bills": { - const rows = await db.query.vendorBills.findMany({ - limit: cap, - }) + // join through projects to filter by org + const rows = await db + .select({ + id: vendorBills.id, + netsuiteId: vendorBills.netsuiteId, + vendorId: vendorBills.vendorId, + projectId: vendorBills.projectId, + billNumber: vendorBills.billNumber, + status: vendorBills.status, + billDate: vendorBills.billDate, + dueDate: vendorBills.dueDate, + subtotal: vendorBills.subtotal, + tax: vendorBills.tax, + total: vendorBills.total, + amountPaid: vendorBills.amountPaid, + amountDue: vendorBills.amountDue, + memo: vendorBills.memo, + lineItems: vendorBills.lineItems, + createdAt: vendorBills.createdAt, + updatedAt: vendorBills.updatedAt, + }) + .from(vendorBills) + .innerJoin(projects, eq(vendorBills.projectId, projects.id)) + .where(eq(projects.organizationId, orgId)) + .limit(cap) return { data: rows, count: rows.length } } case "schedule_tasks": { - const rows = await db.query.scheduleTasks.findMany({ - limit: cap, - ...(input.search - ? { - where: (t, { like }) => - like(t.title, `%${input.search}%`), - } - : {}), - }) + // join through projects to filter by org + const whereConditions = [eq(projects.organizationId, orgId)] + if (input.search) { + whereConditions.push(like(scheduleTasks.title, `%${input.search}%`)) + } + const rows = await db + .select({ + id: scheduleTasks.id, + projectId: scheduleTasks.projectId, + title: scheduleTasks.title, + startDate: scheduleTasks.startDate, + workdays: scheduleTasks.workdays, + endDateCalculated: scheduleTasks.endDateCalculated, + phase: scheduleTasks.phase, + status: scheduleTasks.status, + isCriticalPath: scheduleTasks.isCriticalPath, + isMilestone: scheduleTasks.isMilestone, + percentComplete: scheduleTasks.percentComplete, + assignedTo: scheduleTasks.assignedTo, + sortOrder: scheduleTasks.sortOrder, + createdAt: scheduleTasks.createdAt, + updatedAt: scheduleTasks.updatedAt, + }) + .from(scheduleTasks) + .innerJoin(projects, eq(scheduleTasks.projectId, projects.id)) + .where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]) + .limit(cap) return { data: rows, count: rows.length } } @@ -227,7 +302,8 @@ async function executeQueryData(input: QueryDataInput) { return { error: "id required for detail query" } } const row = await db.query.projects.findFirst({ - where: (p, { eq }) => eq(p.id, input.id!), + where: (p, { eq: eqFunc, and: andFunc }) => + andFunc(eqFunc(p.id, input.id!), eqFunc(p.organizationId, orgId)), }) return row ? { data: row } : { error: "not found" } } @@ -237,7 +313,8 @@ async function executeQueryData(input: QueryDataInput) { return { error: "id required for detail query" } } const row = await db.query.customers.findFirst({ - where: (c, { eq }) => eq(c.id, input.id!), + where: (c, { eq: eqFunc, and: andFunc }) => + andFunc(eqFunc(c.id, input.id!), eqFunc(c.organizationId, orgId)), }) return row ? { data: row } : { error: "not found" } } @@ -247,7 +324,8 @@ async function executeQueryData(input: QueryDataInput) { return { error: "id required for detail query" } } const row = await db.query.vendors.findFirst({ - where: (v, { eq }) => eq(v.id, input.id!), + where: (v, { eq: eqFunc, and: andFunc }) => + andFunc(eqFunc(v.id, input.id!), eqFunc(v.organizationId, orgId)), }) return row ? { data: row } : { error: "not found" } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f2e5ef6..1281666 100755 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,9 +1,11 @@ import { withAuth, signOut } from "@workos-inc/authkit-nextjs" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" -import { users } from "@/db/schema" +import { users, organizations, organizationMembers } from "@/db/schema" import type { User } from "@/db/schema" import { eq } from "drizzle-orm" +import { cookies } from "next/headers" +import { DEMO_USER } from "@/lib/demo" export type AuthUser = { readonly id: string @@ -16,6 +18,9 @@ export type AuthUser = { readonly googleEmail: string | null readonly isActive: boolean readonly lastLoginAt: string | null + readonly organizationId: string | null + readonly organizationName: string | null + readonly organizationType: string | null readonly createdAt: string readonly updatedAt: string } @@ -48,6 +53,15 @@ export function toSidebarUser(user: AuthUser): SidebarUser { export async function getCurrentUser(): Promise { try { + // check for demo session cookie first + try { + const cookieStore = await cookies() + const isDemoSession = cookieStore.get("compass-demo")?.value === "true" + if (isDemoSession) return DEMO_USER + } catch { + // cookies() may throw in non-request contexts + } + // check if workos is configured const isWorkOSConfigured = process.env.WORKOS_API_KEY && @@ -67,6 +81,9 @@ export async function getCurrentUser(): Promise { googleEmail: null, isActive: true, lastLoginAt: new Date().toISOString(), + organizationId: "hps-org-001", + organizationName: "HPS", + organizationType: "internal", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -102,6 +119,36 @@ export async function getCurrentUser(): Promise { .where(eq(users.id, workosUser.id)) .run() + // query org memberships + const orgMemberships = await db + .select({ + orgId: organizations.id, + orgName: organizations.name, + orgType: organizations.type, + memberRole: organizationMembers.role, + }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId) + ) + .where(eq(organizationMembers.userId, dbUser.id)) + + let activeOrg: { orgId: string; orgName: string; orgType: string } | null = + null + + if (orgMemberships.length > 0) { + // check for cookie preference + try { + const cookieStore = await cookies() + const preferredOrg = cookieStore.get("compass-active-org")?.value + const match = orgMemberships.find((m) => m.orgId === preferredOrg) + activeOrg = match ?? orgMemberships[0] + } catch { + activeOrg = orgMemberships[0] + } + } + return { id: dbUser.id, email: dbUser.email, @@ -113,6 +160,9 @@ export async function getCurrentUser(): Promise { googleEmail: dbUser.googleEmail ?? null, isActive: dbUser.isActive, lastLoginAt: now, + organizationId: activeOrg?.orgId ?? null, + organizationName: activeOrg?.orgName ?? null, + organizationType: activeOrg?.orgType ?? null, createdAt: dbUser.createdAt, updatedAt: dbUser.updatedAt, } @@ -166,6 +216,50 @@ export async function ensureUserExists(workosUser: { await db.insert(users).values(newUser).run() + // create personal org + const personalOrgId = crypto.randomUUID() + const personalSlug = `${workosUser.id.slice(0, 8)}-personal` + + await db + .insert(organizations) + .values({ + id: personalOrgId, + name: `${workosUser.firstName ?? "User"}'s Workspace`, + slug: personalSlug, + type: "personal", + logoUrl: null, + isActive: true, + createdAt: now, + updatedAt: now, + }) + .run() + + // add user as admin member + await db + .insert(organizationMembers) + .values({ + id: crypto.randomUUID(), + organizationId: personalOrgId, + userId: workosUser.id, + role: "admin", + joinedAt: now, + }) + .run() + + // set active org cookie + try { + const cookieStore = await cookies() + cookieStore.set("compass-active-org", personalOrgId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + }) + } catch { + // may not be in request context + } + return newUser as User } diff --git a/src/lib/demo.ts b/src/lib/demo.ts new file mode 100644 index 0000000..e9532ae --- /dev/null +++ b/src/lib/demo.ts @@ -0,0 +1,30 @@ +import type { AuthUser } from "./auth" + +export const DEMO_ORG_ID = "demo-org-meridian" +export const DEMO_USER_ID = "demo-user-001" + +export const DEMO_USER: AuthUser = { + id: DEMO_USER_ID, + email: "demo@compass.build", + firstName: "Demo", + lastName: "User", + displayName: "Demo User", + avatarUrl: null, + role: "admin", + googleEmail: null, + isActive: true, + lastLoginAt: new Date().toISOString(), + organizationId: DEMO_ORG_ID, + organizationName: "Meridian Group", + organizationType: "demo", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +} + +export function isDemoUser(userId: string): boolean { + return userId === DEMO_USER_ID +} + +export function isDemoOrg(orgId: string): boolean { + return orgId === DEMO_ORG_ID +} diff --git a/src/lib/org-scope.ts b/src/lib/org-scope.ts new file mode 100644 index 0000000..7436480 --- /dev/null +++ b/src/lib/org-scope.ts @@ -0,0 +1,8 @@ +import type { AuthUser } from "./auth" + +export function requireOrg(user: AuthUser): string { + if (!user.organizationId) { + throw new Error("No active organization") + } + return user.organizationId +} diff --git a/src/middleware.ts b/src/middleware.ts index fff4217..d5e80b2 100755 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,6 +10,7 @@ const publicPaths = [ "/verify-email", "/invite", "/callback", + "/demo", ] // bridge routes use their own API key auth @@ -40,6 +41,12 @@ export default async function middleware(request: NextRequest) { return handleAuthkitHeaders(request, headers) } + // demo sessions bypass auth + const isDemoSession = request.cookies.get("compass-demo")?.value === "true" + if (isDemoSession) { + return handleAuthkitHeaders(request, headers) + } + // redirect unauthenticated users to our custom login page if (!session.user) { const loginUrl = new URL("/login", request.url) diff --git a/tsconfig.json b/tsconfig.json index 2549f52..2c21b01 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ ] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "references", "packages"] + "exclude": ["node_modules", "references", "packages", "scripts"] } From 6f6ec5f31b99b5dd67a20033a26672a35c1e24a4 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 15 Feb 2026 22:14:23 -0700 Subject: [PATCH 2/2] fix(auth): real session takes priority over demo cookie The demo cookie was checked unconditionally before WorkOS auth, so logging in with real credentials after visiting /demo still returned the demo user. Now getCurrentUser() tries WorkOS first and only falls back to the demo cookie when no real session exists. Clears the stale cookie on real login. --- src/lib/auth.ts | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1281666..32d456b 100755 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -53,15 +53,6 @@ export function toSidebarUser(user: AuthUser): SidebarUser { export async function getCurrentUser(): Promise { try { - // check for demo session cookie first - try { - const cookieStore = await cookies() - const isDemoSession = cookieStore.get("compass-demo")?.value === "true" - if (isDemoSession) return DEMO_USER - } catch { - // cookies() may throw in non-request contexts - } - // check if workos is configured const isWorkOSConfigured = process.env.WORKOS_API_KEY && @@ -69,6 +60,16 @@ export async function getCurrentUser(): Promise { !process.env.WORKOS_API_KEY.includes("placeholder") if (!isWorkOSConfigured) { + // check demo cookie when WorkOS isn't available + try { + const cookieStore = await cookies() + const isDemoSession = + cookieStore.get("compass-demo")?.value === "true" + if (isDemoSession) return DEMO_USER + } catch { + // cookies() may throw in non-request contexts + } + // return mock user for development return { id: "dev-user-1", @@ -89,8 +90,31 @@ export async function getCurrentUser(): Promise { } } + // WorkOS is configured -- try real auth first const session = await withAuth() - if (!session || !session.user) return null + + if (!session || !session.user) { + // no real session; fall back to demo cookie + try { + const cookieStore = await cookies() + const isDemoSession = + cookieStore.get("compass-demo")?.value === "true" + if (isDemoSession) return DEMO_USER + } catch { + // cookies() may throw in non-request contexts + } + return null + } + + // real session exists -- clear stale demo cookie if present + try { + const cookieStore = await cookies() + if (cookieStore.get("compass-demo")) { + cookieStore.delete("compass-demo") + } + } catch { + // cookies() may throw in non-request contexts + } const workosUser = session.user