From ad2f0c0b9cefe3c125dc98d2b7a6fd878baa5200 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 15 Feb 2026 22:05:12 -0700 Subject: [PATCH] feat(security): add multi-tenancy isolation and demo mode (#90) 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. Co-authored-by: Nicholai --- 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 | 129 +- 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, 7590 insertions(+), 277 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() - .from(organizations) - .where(eq(organizations.isActive, true)) - return allOrganizations + // 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) + .innerJoin(organizationMembers, eq(organizations.id, organizationMembers.organizationId)) + .where(and(eq(organizations.isActive, true), eq(organizationMembers.userId, currentUser.id))) + + 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"] }