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 <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-15 22:05:12 -07:00 committed by GitHub
parent 49518d3633
commit ad2f0c0b9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 7590 additions and 277 deletions

View File

@ -18,6 +18,7 @@ How the core platform works.
- [server actions](architecture/server-actions.md) -- the data mutation pattern, auth checks, error handling, revalidation - [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 - [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 - [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 modules

View File

@ -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.

View File

@ -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);

File diff suppressed because it is too large Load Diff

View File

@ -183,6 +183,13 @@
"when": 1771205359100, "when": 1771205359100,
"tag": "0025_chunky_silverclaw", "tag": "0025_chunky_silverclaw",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1771215013379,
"tag": "0026_easy_professor_monster",
"breakpoints": true
} }
] ]
} }

View File

@ -14,6 +14,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply compass-db --local", "db:migrate:local": "wrangler d1 migrations apply compass-db --local",
"db:migrate:prod": "wrangler d1 migrations apply compass-db --remote", "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", "prepare": "husky",
"cap:sync": "cap sync", "cap:sync": "cap sync",
"cap:ios": "cap open ios", "cap:ios": "cap open ios",

142
scripts/migrate-to-orgs.ts Normal file
View File

@ -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()

284
scripts/seed-demo.ts Normal file
View File

@ -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<string, string[]> = {
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)

View File

@ -5,6 +5,7 @@ import { getDb } from "@/db"
import { agentConversations, agentMemories } from "@/db/schema" import { agentConversations, agentMemories } from "@/db/schema"
import { eq, desc } from "drizzle-orm" import { eq, desc } from "drizzle-orm"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { isDemoUser } from "@/lib/demo"
interface SerializedMessage { interface SerializedMessage {
readonly id: string readonly id: string
@ -23,6 +24,10 @@ export async function saveConversation(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" } if (!user) return { success: false, error: "Unauthorized" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const now = new Date().toISOString() const now = new Date().toISOString()
@ -181,6 +186,10 @@ export async function deleteConversation(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" } if (!user) return { success: false, error: "Unauthorized" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)

View File

@ -442,21 +442,14 @@ export async function getConversationUsage(
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const isAdmin = can(user, "agent", "update")
const rows = await db const rows = await db
.select() .select()
.from(agentUsage) .from(agentUsage)
.where( .where(
isAdmin and(
? eq(agentUsage.conversationId, conversationId) eq(agentUsage.conversationId, conversationId),
: and( eq(agentUsage.userId, user.id)
eq( )
agentUsage.conversationId,
conversationId
),
eq(agentUsage.userId, user.id)
)
) )
.orderBy(desc(agentUsage.createdAt)) .orderBy(desc(agentUsage.createdAt))
.all() .all()

View File

@ -6,17 +6,35 @@ import {
scheduleBaselines, scheduleBaselines,
scheduleTasks, scheduleTasks,
taskDependencies, taskDependencies,
projects,
} from "@/db/schema" } from "@/db/schema"
import { eq, asc } from "drizzle-orm" import { eq, asc, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" 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" import type { ScheduleBaselineData } from "@/lib/schedule/types"
export async function getBaselines( export async function getBaselines(
projectId: string projectId: string
): Promise<ScheduleBaselineData[]> { ): Promise<ScheduleBaselineData[]> {
const user = await requireAuth()
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 return await db
.select() .select()
.from(scheduleBaselines) .from(scheduleBaselines)
@ -28,9 +46,26 @@ export async function createBaseline(
name: string name: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 const tasks = await db
.select() .select()
.from(scheduleTasks) .from(scheduleTasks)
@ -65,6 +100,12 @@ export async function deleteBaseline(
baselineId: string baselineId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -76,6 +117,17 @@ export async function deleteBaseline(
if (!existing) return { success: false, error: "Baseline not found" } 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 await db
.delete(scheduleBaselines) .delete(scheduleBaselines)
.where(eq(scheduleBaselines.id, baselineId)) .where(eq(scheduleBaselines.id, baselineId))

View File

@ -7,6 +7,8 @@ import { channelCategories, channels, type NewChannelCategory } from "@/db/schem
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function listCategories() { export async function listCategories() {
try { try {
@ -14,22 +16,11 @@ export async function listCategories() {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// get user's organization
const orgMember = await db
.select({ organizationId: sql<string>`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 // fetch categories with channel counts
const categories = await db const categories = await db
.select({ .select({
@ -43,7 +34,7 @@ export async function listCategories() {
)`, )`,
}) })
.from(channelCategories) .from(channelCategories)
.where(eq(channelCategories.organizationId, orgMember.organizationId)) .where(eq(channelCategories.organizationId, orgId))
.orderBy(channelCategories.position) .orderBy(channelCategories.position)
return { success: true, data: categories } return { success: true, data: categories }
@ -62,31 +53,24 @@ export async function createCategory(name: string, position?: number) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
// admin only // admin only
requirePermission(user, "channels", "create") requirePermission(user, "channels", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// get user's organization
const orgMember = await db
.select({ organizationId: sql<string>`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 categoryId = crypto.randomUUID()
const now = new Date().toISOString() const now = new Date().toISOString()
const newCategory: NewChannelCategory = { const newCategory: NewChannelCategory = {
id: categoryId, id: categoryId,
name, name,
organizationId: orgMember.organizationId, organizationId: orgId,
position: position ?? 0, position: position ?? 0,
collapsedByDefault: false, collapsedByDefault: false,
createdAt: now, createdAt: now,
@ -114,33 +98,26 @@ export async function updateCategory(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
// admin only // admin only
requirePermission(user, "channels", "create") requirePermission(user, "channels", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env} = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// get user's organization
const orgMember = await db
.select({ organizationId: sql<string>`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 // verify category exists in user's org
const category = await db const category = await db
.select() .select()
.from(channelCategories) .from(channelCategories)
.where(eq(channelCategories.id, id)) .where(and(eq(channelCategories.id, id), eq(channelCategories.organizationId, orgId)))
.limit(1) .limit(1)
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
if (!category || category.organizationId !== orgMember.organizationId) { if (!category) {
return { success: false, error: "Category not found" } return { success: false, error: "Category not found" }
} }
@ -179,33 +156,26 @@ export async function deleteCategory(id: string) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
// admin only // admin only
requirePermission(user, "channels", "create") requirePermission(user, "channels", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// get user's organization
const orgMember = await db
.select({ organizationId: sql<string>`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 // verify category exists in user's org
const category = await db const category = await db
.select() .select()
.from(channelCategories) .from(channelCategories)
.where(eq(channelCategories.id, id)) .where(and(eq(channelCategories.id, id), eq(channelCategories.organizationId, orgId)))
.limit(1) .limit(1)
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
if (!category || category.organizationId !== orgMember.organizationId) { if (!category) {
return { success: false, error: "Category not found" } return { success: false, error: "Category not found" }
} }
@ -247,32 +217,25 @@ export async function reorderChannels(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "channels", "update") requirePermission(user, "channels", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// get user's organization
const orgMember = await db
.select({ organizationId: sql<string>`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 // verify category exists and belongs to user's org
const category = await db const category = await db
.select() .select()
.from(channelCategories) .from(channelCategories)
.where(eq(channelCategories.id, categoryId)) .where(and(eq(channelCategories.id, categoryId), eq(channelCategories.organizationId, orgId)))
.limit(1) .limit(1)
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
if (!category || category.organizationId !== orgMember.organizationId) { if (!category) {
return { success: false, error: "Category not found" } return { success: false, error: "Category not found" }
} }

View File

@ -17,6 +17,8 @@ import {
import { users, organizationMembers } from "@/db/schema" import { users, organizationMembers } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { isDemoUser } from "@/lib/demo"
import { requireOrg } from "@/lib/org-scope"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
const MAX_MESSAGE_LENGTH = 4000 const MAX_MESSAGE_LENGTH = 4000
@ -89,8 +91,7 @@ async function renderMarkdown(content: string): Promise<string> {
} }
export async function searchMentionableUsers( export async function searchMentionableUsers(
query: string, query: string
organizationId: string
) { ) {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
@ -98,6 +99,8 @@ export async function searchMentionableUsers(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
const organizationId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -158,6 +161,9 @@ export async function sendMessage(data: {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
if (data.content.length > MAX_MESSAGE_LENGTH) { if (data.content.length > MAX_MESSAGE_LENGTH) {
return { success: false, error: `Message too long (max ${MAX_MESSAGE_LENGTH} characters)` } return { success: false, error: `Message too long (max ${MAX_MESSAGE_LENGTH} characters)` }
@ -315,6 +321,9 @@ export async function editMessage(
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -393,6 +402,9 @@ export async function deleteMessage(messageId: string) {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -621,6 +633,9 @@ export async function addReaction(messageId: string, emoji: string) {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) { if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) {
return { success: false, error: "Invalid emoji" } return { success: false, error: "Invalid emoji" }
@ -705,6 +720,9 @@ export async function removeReaction(messageId: string, emoji: string) {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) { if (emoji.length > 10 || !EMOJI_REGEX.test(emoji)) {
return { success: false, error: "Invalid emoji" } return { success: false, error: "Invalid emoji" }

View File

@ -15,6 +15,8 @@ import { users, organizationMembers } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function listChannels() { export async function listChannels() {
try { try {
@ -22,6 +24,7 @@ export async function listChannels() {
if (!user) { if (!user) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -64,10 +67,7 @@ export async function listChannels() {
.where( .where(
and( and(
// must be in user's org // must be in user's org
sql`${channels.organizationId} = ( eq(channels.organizationId, orgId),
SELECT organization_id FROM organization_members
WHERE user_id = ${user.id} LIMIT 1
)`,
// if private, must be a member // if private, must be a member
sql`(${channels.isPrivate} = 0 OR ${channelMembers.userId} IS NOT NULL)`, sql`(${channels.isPrivate} = 0 OR ${channelMembers.userId} IS NOT NULL)`,
// not archived // not archived
@ -107,6 +107,11 @@ export async function getChannel(channelId: string) {
return { success: false, error: "Channel not found" } 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 private, check membership
if (channel.isPrivate) { if (channel.isPrivate) {
const membership = await db const membership = await db
@ -162,24 +167,17 @@ export async function createChannel(data: {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
// only office+ can create channels // only office+ can create channels
requirePermission(user, "channels", "create") requirePermission(user, "channels", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 now = new Date().toISOString()
const channelId = crypto.randomUUID() const channelId = crypto.randomUUID()
@ -188,7 +186,7 @@ export async function createChannel(data: {
name: data.name, name: data.name,
type: data.type, type: data.type,
description: data.description ?? null, description: data.description ?? null,
organizationId: orgMember.organizationId, organizationId: orgId,
projectId: data.projectId ?? null, projectId: data.projectId ?? null,
categoryId: data.categoryId ?? null, categoryId: data.categoryId ?? null,
isPrivate: data.isPrivate ?? false, isPrivate: data.isPrivate ?? false,
@ -242,6 +240,10 @@ export async function joinChannel(channelId: string) {
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -257,6 +259,11 @@ export async function joinChannel(channelId: string) {
return { success: false, error: "Channel not found" } 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) { if (channel.isPrivate) {
return { return {
success: false, success: false,

View File

@ -1,34 +1,76 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { creditMemos, type NewCreditMemo } from "@/db/schema-netsuite" 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 { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getCreditMemos() { export async function getCreditMemos() {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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) { export async function getCreditMemo(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// join through project to verify org
const rows = await db 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) .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) .limit(1)
return rows[0] ?? null return rows[0] ?? null
@ -38,12 +80,29 @@ export async function createCreditMemo(
data: Omit<NewCreditMemo, "id" | "createdAt" | "updatedAt"> data: Omit<NewCreditMemo, "id" | "createdAt" | "updatedAt">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "create") requirePermission(user, "finance", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 now = new Date().toISOString()
const id = crypto.randomUUID() const id = crypto.randomUUID()
@ -72,12 +131,28 @@ export async function updateCreditMemo(
data: Partial<NewCreditMemo> data: Partial<NewCreditMemo>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "update") requirePermission(user, "finance", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 await db
.update(creditMemos) .update(creditMemos)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
@ -98,12 +173,28 @@ export async function updateCreditMemo(
export async function deleteCreditMemo(id: string) { export async function deleteCreditMemo(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "delete") requirePermission(user, "finance", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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)) await db.delete(creditMemos).where(eq(creditMemos.id, id))
revalidatePath("/dashboard/financials") revalidatePath("/dashboard/financials")

View File

@ -1,26 +1,30 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { customers, type NewCustomer } from "@/db/schema" import { customers, type NewCustomer } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth" import { requireAuth } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getCustomers() { export async function getCustomers() {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "customer", "read") requirePermission(user, "customer", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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) { export async function getCustomer(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "customer", "read") requirePermission(user, "customer", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -28,18 +32,22 @@ export async function getCustomer(id: string) {
const rows = await db const rows = await db
.select() .select()
.from(customers) .from(customers)
.where(eq(customers.id, id)) .where(and(eq(customers.id, id), eq(customers.organizationId, orgId)))
.limit(1) .limit(1)
return rows[0] ?? null return rows[0] ?? null
} }
export async function createCustomer( export async function createCustomer(
data: Omit<NewCustomer, "id" | "createdAt" | "updatedAt"> data: Omit<NewCustomer, "id" | "createdAt" | "updatedAt" | "organizationId">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "customer", "create") requirePermission(user, "customer", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -49,6 +57,7 @@ export async function createCustomer(
await db.insert(customers).values({ await db.insert(customers).values({
id, id,
organizationId: orgId,
...data, ...data,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -70,8 +79,12 @@ export async function updateCustomer(
data: Partial<NewCustomer> data: Partial<NewCustomer>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "customer", "update") requirePermission(user, "customer", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -79,7 +92,7 @@ export async function updateCustomer(
await db await db
.update(customers) .update(customers)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(customers.id, id)) .where(and(eq(customers.id, id), eq(customers.organizationId, orgId)))
revalidatePath("/dashboard/customers") revalidatePath("/dashboard/customers")
return { success: true } return { success: true }
@ -94,13 +107,19 @@ export async function updateCustomer(
export async function deleteCustomer(id: string) { export async function deleteCustomer(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "customer", "delete") requirePermission(user, "customer", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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") revalidatePath("/dashboard/customers")
return { success: true } return { success: true }

View File

@ -1,10 +1,14 @@
"use server" "use server"
import { eq, and, desc } from "drizzle-orm" import { eq, and, desc, inArray } from "drizzle-orm"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { customDashboards } from "@/db/schema-dashboards" 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 { getCurrentUser } from "@/lib/auth"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
const MAX_DASHBOARDS = 5 const MAX_DASHBOARDS = 5
@ -109,6 +113,10 @@ export async function saveCustomDashboard(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -171,6 +179,10 @@ export async function deleteCustomDashboard(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -207,6 +219,8 @@ export async function executeDashboardQueries(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -228,10 +242,15 @@ export async function executeDashboardQueries(
limit: cap, limit: cap,
...(q.search ...(q.search
? { ? {
where: (c, { like }) => where: (c, { like, eq, and }) =>
like(c.name, `%${q.search}%`), 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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
@ -241,10 +260,15 @@ export async function executeDashboardQueries(
limit: cap, limit: cap,
...(q.search ...(q.search
? { ? {
where: (v, { like }) => where: (v, { like, eq, and }) =>
like(v.name, `%${q.search}%`), 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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
@ -254,38 +278,76 @@ export async function executeDashboardQueries(
limit: cap, limit: cap,
...(q.search ...(q.search
? { ? {
where: (p, { like }) => where: (p, { like, eq, and }) =>
like(p.name, `%${q.search}%`), 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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
} }
case "invoices": { case "invoices": {
const rows = await db.query.invoices.findMany({ const orgProjects = await db
limit: cap, .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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
} }
case "vendor_bills": { case "vendor_bills": {
const rows = await db.query.vendorBills.findMany({ const orgProjects = await db
limit: cap, .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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
} }
case "schedule_tasks": { case "schedule_tasks": {
const rows = await db.query.scheduleTasks.findMany({ const orgProjects = await db
limit: cap, .select({ id: projects.id })
...(q.search .from(projects)
? { .where(eq(projects.organizationId, orgId))
where: (t, { like }) => const projectIds = orgProjects.map((p) => p.id)
like(t.title, `%${q.search}%`), 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 } dataContext[q.key] = { data: rows, count: rows.length }
break break
} }
@ -294,9 +356,13 @@ export async function executeDashboardQueries(
const row = await db.query.projects.findFirst({ const row = await db.query.projects.findFirst({
where: (p, { eq: e }) => e(p.id, q.id!), where: (p, { eq: e }) => e(p.id, q.id!),
}) })
dataContext[q.key] = row if (row && row.organizationId !== orgId) {
? { data: row } dataContext[q.key] = { error: "not found" }
: { error: "not found" } } else {
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
} }
break break
} }
@ -305,9 +371,13 @@ export async function executeDashboardQueries(
const row = await db.query.customers.findFirst({ const row = await db.query.customers.findFirst({
where: (c, { eq: e }) => e(c.id, q.id!), where: (c, { eq: e }) => e(c.id, q.id!),
}) })
dataContext[q.key] = row if (row && row.organizationId !== orgId) {
? { data: row } dataContext[q.key] = { error: "not found" }
: { error: "not found" } } else {
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
} }
break break
} }
@ -316,9 +386,13 @@ export async function executeDashboardQueries(
const row = await db.query.vendors.findFirst({ const row = await db.query.vendors.findFirst({
where: (v, { eq: e }) => e(v.id, q.id!), where: (v, { eq: e }) => e(v.id, q.id!),
}) })
dataContext[q.key] = row if (row && row.organizationId !== orgId) {
? { data: row } dataContext[q.key] = { error: "not found" }
: { error: "not found" } } else {
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
} }
break break
} }

View File

@ -3,21 +3,27 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { groups, type Group, type NewGroup } from "@/db/schema" 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 { requirePermission } from "@/lib/permissions"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getGroups(): Promise<Group[]> { export async function getGroups(): Promise<Group[]> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
requirePermission(currentUser, "group", "read") requirePermission(currentUser, "group", "read")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return [] if (!env?.DB) return []
const db = getDb(env.DB) 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 return allGroups
} catch (error) { } catch (error) {
@ -27,14 +33,17 @@ export async function getGroups(): Promise<Group[]> {
} }
export async function createGroup( export async function createGroup(
organizationId: string,
name: string, name: string,
description?: string, description?: string,
color?: string color?: string
): Promise<{ success: boolean; error?: string; data?: Group }> { ): Promise<{ success: boolean; error?: string; data?: Group }> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
if (isDemoUser(currentUser.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(currentUser, "group", "create") requirePermission(currentUser, "group", "create")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) { if (!env?.DB) {
@ -46,7 +55,7 @@ export async function createGroup(
const newGroup: NewGroup = { const newGroup: NewGroup = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
organizationId, organizationId: orgId,
name, name,
description: description ?? null, description: description ?? null,
color: color ?? null, color: color ?? null,
@ -70,8 +79,12 @@ export async function deleteGroup(
groupId: string groupId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
if (isDemoUser(currentUser.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(currentUser, "group", "delete") requirePermission(currentUser, "group", "delete")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) { if (!env?.DB) {
@ -80,7 +93,10 @@ export async function deleteGroup(
const db = getDb(env.DB) 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") revalidatePath("/dashboard/people")
return { success: true } return { success: true }

View File

@ -1,40 +1,100 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { invoices, type NewInvoice } from "@/db/schema-netsuite" 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 { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getInvoices(projectId?: string) { export async function getInvoices(projectId?: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
if (projectId) { 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 return db
.select() .select()
.from(invoices) .from(invoices)
.where(eq(invoices.projectId, projectId)) .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) { export async function getInvoice(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// join through project to verify org
const rows = await db 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) .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) .limit(1)
return rows[0] ?? null return rows[0] ?? null
@ -44,12 +104,29 @@ export async function createInvoice(
data: Omit<NewInvoice, "id" | "createdAt" | "updatedAt"> data: Omit<NewInvoice, "id" | "createdAt" | "updatedAt">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "create") requirePermission(user, "finance", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 now = new Date().toISOString()
const id = crypto.randomUUID() const id = crypto.randomUUID()
@ -75,12 +152,28 @@ export async function updateInvoice(
data: Partial<NewInvoice> data: Partial<NewInvoice>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "update") requirePermission(user, "finance", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 await db
.update(invoices) .update(invoices)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
@ -98,12 +191,28 @@ export async function updateInvoice(
export async function deleteInvoice(id: string) { export async function deleteInvoice(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "delete") requirePermission(user, "finance", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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)) await db.delete(invoices).where(eq(invoices.id, id))
revalidatePath("/dashboard/financials") revalidatePath("/dashboard/financials")

View File

@ -10,6 +10,7 @@ import {
hashApiKey, hashApiKey,
} from "@/lib/mcp/auth" } from "@/lib/mcp/auth"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { isDemoUser } from "@/lib/demo"
export async function createApiKey( export async function createApiKey(
name: string, name: string,
@ -24,6 +25,10 @@ export async function createApiKey(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const now = new Date().toISOString() const now = new Date().toISOString()
@ -129,6 +134,10 @@ export async function revokeApiKey(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -179,6 +188,10 @@ export async function deleteApiKey(
return { success: false, error: "Unauthorized" } return { success: false, error: "Unauthorized" }
} }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)

View File

@ -2,27 +2,42 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" 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 { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { cookies } from "next/headers"
import { isDemoUser } from "@/lib/demo"
export async function getOrganizations(): Promise<Organization[]> { export async function getOrganizations(): Promise<Organization[]> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await getCurrentUser()
if (!currentUser) return []
requirePermission(currentUser, "organization", "read") requirePermission(currentUser, "organization", "read")
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return [] if (!env?.DB) return []
const db = getDb(env.DB) 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) { } catch (error) {
console.error("Error fetching organizations:", error) console.error("Error fetching organizations:", error)
return [] return []
@ -32,10 +47,16 @@ export async function getOrganizations(): Promise<Organization[]> {
export async function createOrganization( export async function createOrganization(
name: string, name: string,
slug: string, slug: string,
type: "internal" | "client" type: "internal" | "client" | "personal" | "demo"
): Promise<{ success: boolean; error?: string; data?: Organization }> { ): Promise<{ success: boolean; error?: string; data?: Organization }> {
try { try {
const currentUser = await getCurrentUser() 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") requirePermission(currentUser, "organization", "create")
const { env } = await getCloudflareContext() 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",
}
}
}

View File

@ -1,34 +1,74 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { payments, type NewPayment } from "@/db/schema-netsuite" 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 { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getPayments() { export async function getPayments() {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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) { export async function getPayment(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// join through project to verify org
const rows = await db 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) .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) .limit(1)
return rows[0] ?? null return rows[0] ?? null
@ -38,12 +78,29 @@ export async function createPayment(
data: Omit<NewPayment, "id" | "createdAt" | "updatedAt"> data: Omit<NewPayment, "id" | "createdAt" | "updatedAt">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "create") requirePermission(user, "finance", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 now = new Date().toISOString()
const id = crypto.randomUUID() const id = crypto.randomUUID()
@ -70,12 +127,28 @@ export async function updatePayment(
data: Partial<NewPayment> data: Partial<NewPayment>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "update") requirePermission(user, "finance", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 await db
.update(payments) .update(payments)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
@ -94,12 +167,28 @@ export async function updatePayment(
export async function deletePayment(id: string) { export async function deletePayment(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "delete") requirePermission(user, "finance", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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)) await db.delete(payments).where(eq(payments.id, id))
revalidatePath("/dashboard/financials") revalidatePath("/dashboard/financials")

View File

@ -11,6 +11,7 @@ import {
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { fetchSkillFromGitHub } from "@/lib/agent/plugins/skills-client" import { fetchSkillFromGitHub } from "@/lib/agent/plugins/skills-client"
import { clearRegistryCache } from "@/lib/agent/plugins/registry" import { clearRegistryCache } from "@/lib/agent/plugins/registry"
import { isDemoUser } from "@/lib/demo"
function skillId(source: string): string { function skillId(source: string): string {
return "skill-" + source.replace(/\//g, "-").toLowerCase() return "skill-" + source.replace(/\//g, "-").toLowerCase()
@ -23,6 +24,10 @@ export async function installSkill(source: string): Promise<
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -103,6 +108,10 @@ export async function uninstallSkill(pluginId: string): Promise<
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -137,6 +146,10 @@ export async function toggleSkill(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const now = new Date().toISOString() const now = new Date().toISOString()

View File

@ -3,10 +3,15 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { projects } from "@/db/schema" 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 }[]> { export async function getProjects(): Promise<{ id: string; name: string }[]> {
try { try {
const user = await requireAuth()
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return [] if (!env?.DB) return []
@ -14,6 +19,7 @@ export async function getProjects(): Promise<{ id: string; name: string }[]> {
const allProjects = await db const allProjects = await db
.select({ id: projects.id, name: projects.name }) .select({ id: projects.id, name: projects.name })
.from(projects) .from(projects)
.where(eq(projects.organizationId, orgId))
.orderBy(asc(projects.name)) .orderBy(asc(projects.name))
return allProjects return allProjects

View File

@ -8,12 +8,15 @@ import {
workdayExceptions, workdayExceptions,
projects, projects,
} from "@/db/schema" } from "@/db/schema"
import { eq, asc } from "drizzle-orm" import { eq, asc, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { calculateEndDate } from "@/lib/schedule/business-days" import { calculateEndDate } from "@/lib/schedule/business-days"
import { findCriticalPath } from "@/lib/schedule/critical-path" import { findCriticalPath } from "@/lib/schedule/critical-path"
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation" import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
import { propagateDates } from "@/lib/schedule/propagate-dates" 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 { import type {
TaskStatus, TaskStatus,
DependencyType, DependencyType,
@ -42,9 +45,23 @@ async function fetchExceptions(
export async function getSchedule( export async function getSchedule(
projectId: string projectId: string
): Promise<ScheduleData> { ): Promise<ScheduleData> {
const user = await requireAuth()
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 const tasks = await db
.select() .select()
.from(scheduleTasks) .from(scheduleTasks)
@ -86,9 +103,26 @@ export async function createTask(
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 exceptions = await fetchExceptions(db, projectId)
const endDate = calculateEndDate( const endDate = calculateEndDate(
data.startDate, data.workdays, exceptions data.startDate, data.workdays, exceptions
@ -146,6 +180,12 @@ export async function updateTask(
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -157,6 +197,17 @@ export async function updateTask(
if (!task) return { success: false, error: "Task not found" } 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 exceptions = await fetchExceptions(db, task.projectId)
const startDate = data.startDate ?? task.startDate const startDate = data.startDate ?? task.startDate
const workdays = data.workdays ?? task.workdays const workdays = data.workdays ?? task.workdays
@ -223,6 +274,12 @@ export async function deleteTask(
taskId: string taskId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -234,6 +291,17 @@ export async function deleteTask(
if (!task) return { success: false, error: "Task not found" } 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 db.delete(scheduleTasks).where(eq(scheduleTasks.id, taskId))
await recalcCriticalPath(db, task.projectId) await recalcCriticalPath(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`) revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
@ -249,9 +317,26 @@ export async function reorderTasks(
items: { id: string; sortOrder: number }[] items: { id: string; sortOrder: number }[]
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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) { for (const item of items) {
await db await db
.update(scheduleTasks) .update(scheduleTasks)
@ -275,9 +360,26 @@ export async function createDependency(data: {
projectId: string projectId: string
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 // get existing deps for cycle check
const schedule = await getSchedule(data.projectId) const schedule = await getSchedule(data.projectId)
@ -327,9 +429,26 @@ export async function deleteDependency(
projectId: string projectId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 db.delete(taskDependencies).where(eq(taskDependencies.id, depId))
await recalcCriticalPath(db, projectId) await recalcCriticalPath(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`) revalidatePath(`/dashboard/projects/${projectId}/schedule`)
@ -345,6 +464,12 @@ export async function updateTaskStatus(
status: TaskStatus status: TaskStatus
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -356,6 +481,17 @@ export async function updateTaskStatus(
if (!task) return { success: false, error: "Task not found" } 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 await db
.update(scheduleTasks) .update(scheduleTasks)
.set({ status, updatedAt: new Date().toISOString() }) .set({ status, updatedAt: new Date().toISOString() })

View File

@ -3,21 +3,27 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { teams, type Team, type NewTeam } from "@/db/schema" 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 { requirePermission } from "@/lib/permissions"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getTeams(): Promise<Team[]> { export async function getTeams(): Promise<Team[]> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
requirePermission(currentUser, "team", "read") requirePermission(currentUser, "team", "read")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return [] if (!env?.DB) return []
const db = getDb(env.DB) 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 return allTeams
} catch (error) { } catch (error) {
@ -27,13 +33,16 @@ export async function getTeams(): Promise<Team[]> {
} }
export async function createTeam( export async function createTeam(
organizationId: string,
name: string, name: string,
description?: string description?: string
): Promise<{ success: boolean; error?: string; data?: Team }> { ): Promise<{ success: boolean; error?: string; data?: Team }> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
if (isDemoUser(currentUser.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(currentUser, "team", "create") requirePermission(currentUser, "team", "create")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) { if (!env?.DB) {
@ -45,7 +54,7 @@ export async function createTeam(
const newTeam: NewTeam = { const newTeam: NewTeam = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
organizationId, organizationId: orgId,
name, name,
description: description ?? null, description: description ?? null,
createdAt: now, createdAt: now,
@ -68,8 +77,12 @@ export async function deleteTeam(
teamId: string teamId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const currentUser = await getCurrentUser() const currentUser = await requireAuth()
if (isDemoUser(currentUser.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(currentUser, "team", "delete") requirePermission(currentUser, "team", "delete")
const orgId = requireOrg(currentUser)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) { if (!env?.DB) {
@ -78,7 +91,10 @@ export async function deleteTeam(
const db = getDb(env.DB) 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") revalidatePath("/dashboard/people")
return { success: true } return { success: true }

View File

@ -10,6 +10,7 @@ import {
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { findPreset } from "@/lib/theme/presets" import { findPreset } from "@/lib/theme/presets"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { isDemoUser } from "@/lib/demo"
export async function getUserThemePreference(): Promise< export async function getUserThemePreference(): Promise<
| { readonly success: true; readonly data: { readonly activeThemeId: string } } | { readonly success: true; readonly data: { readonly activeThemeId: string } }
@ -148,6 +149,10 @@ export async function saveCustomTheme(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -191,6 +196,10 @@ export async function deleteCustomTheme(
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)

View File

@ -1,40 +1,100 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { vendorBills, type NewVendorBill } from "@/db/schema-netsuite" 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 { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getVendorBills(projectId?: string) { export async function getVendorBills(projectId?: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
if (projectId) { 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 return db
.select() .select()
.from(vendorBills) .from(vendorBills)
.where(eq(vendorBills.projectId, projectId)) .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) { export async function getVendorBill(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "finance", "read") requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
// join through project to verify org
const rows = await db 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) .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) .limit(1)
return rows[0] ?? null return rows[0] ?? null
@ -44,12 +104,29 @@ export async function createVendorBill(
data: Omit<NewVendorBill, "id" | "createdAt" | "updatedAt"> data: Omit<NewVendorBill, "id" | "createdAt" | "updatedAt">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "create") requirePermission(user, "finance", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 now = new Date().toISOString()
const id = crypto.randomUUID() const id = crypto.randomUUID()
@ -75,12 +152,28 @@ export async function updateVendorBill(
data: Partial<NewVendorBill> data: Partial<NewVendorBill>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "update") requirePermission(user, "finance", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 await db
.update(vendorBills) .update(vendorBills)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
@ -98,12 +191,28 @@ export async function updateVendorBill(
export async function deleteVendorBill(id: string) { export async function deleteVendorBill(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "delete") requirePermission(user, "finance", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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)) await db.delete(vendorBills).where(eq(vendorBills.id, id))
revalidatePath("/dashboard/financials") revalidatePath("/dashboard/financials")

View File

@ -1,26 +1,30 @@
"use server" "use server"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { getDb } from "@/db" import { getDb } from "@/db"
import { vendors, type NewVendor } from "@/db/schema" import { vendors, type NewVendor } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth" import { requireAuth } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions" import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getVendors() { export async function getVendors() {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "vendor", "read") requirePermission(user, "vendor", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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) { export async function getVendor(id: string) {
const user = await getCurrentUser() const user = await requireAuth()
requirePermission(user, "vendor", "read") requirePermission(user, "vendor", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -28,18 +32,22 @@ export async function getVendor(id: string) {
const rows = await db const rows = await db
.select() .select()
.from(vendors) .from(vendors)
.where(eq(vendors.id, id)) .where(and(eq(vendors.id, id), eq(vendors.organizationId, orgId)))
.limit(1) .limit(1)
return rows[0] ?? null return rows[0] ?? null
} }
export async function createVendor( export async function createVendor(
data: Omit<NewVendor, "id" | "createdAt" | "updatedAt"> data: Omit<NewVendor, "id" | "createdAt" | "updatedAt" | "organizationId">
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "vendor", "create") requirePermission(user, "vendor", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -49,6 +57,7 @@ export async function createVendor(
await db.insert(vendors).values({ await db.insert(vendors).values({
id, id,
organizationId: orgId,
...data, ...data,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -70,8 +79,12 @@ export async function updateVendor(
data: Partial<NewVendor> data: Partial<NewVendor>
) { ) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "vendor", "update") requirePermission(user, "vendor", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -79,7 +92,7 @@ export async function updateVendor(
await db await db
.update(vendors) .update(vendors)
.set({ ...data, updatedAt: new Date().toISOString() }) .set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(vendors.id, id)) .where(and(eq(vendors.id, id), eq(vendors.organizationId, orgId)))
revalidatePath("/dashboard/vendors") revalidatePath("/dashboard/vendors")
return { success: true } return { success: true }
@ -94,13 +107,19 @@ export async function updateVendor(
export async function deleteVendor(id: string) { export async function deleteVendor(id: string) {
try { try {
const user = await getCurrentUser() const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "vendor", "delete") requirePermission(user, "vendor", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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") revalidatePath("/dashboard/vendors")
return { success: true } return { success: true }

View File

@ -2,9 +2,12 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { workdayExceptions } from "@/db/schema" import { workdayExceptions, projects } from "@/db/schema"
import { eq } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requireAuth } from "@/lib/auth"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
import type { import type {
WorkdayExceptionData, WorkdayExceptionData,
ExceptionCategory, ExceptionCategory,
@ -14,9 +17,23 @@ import type {
export async function getWorkdayExceptions( export async function getWorkdayExceptions(
projectId: string projectId: string
): Promise<WorkdayExceptionData[]> { ): Promise<WorkdayExceptionData[]> {
const user = await requireAuth()
const orgId = requireOrg(user)
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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 const rows = await db
.select() .select()
.from(workdayExceptions) .from(workdayExceptions)
@ -42,8 +59,26 @@ export async function createWorkdayException(
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) 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() const now = new Date().toISOString()
await db.insert(workdayExceptions).values({ await db.insert(workdayExceptions).values({
@ -81,6 +116,12 @@ export async function updateWorkdayException(
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -92,6 +133,17 @@ export async function updateWorkdayException(
if (!existing) return { success: false, error: "Exception not found" } 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 await db
.update(workdayExceptions) .update(workdayExceptions)
.set({ .set({
@ -120,6 +172,12 @@ export async function deleteWorkdayException(
exceptionId: string exceptionId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -131,6 +189,17 @@ export async function deleteWorkdayException(
if (!existing) return { success: false, error: "Exception not found" } 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 await db
.delete(workdayExceptions) .delete(workdayExceptions)
.where(eq(workdayExceptions.id, exceptionId)) .where(eq(workdayExceptions.id, exceptionId))

View File

@ -20,6 +20,7 @@ import { getRegistry } from "@/lib/agent/plugins/registry"
import { saveStreamUsage } from "@/lib/agent/usage" import { saveStreamUsage } from "@/lib/agent/usage"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db" import { getDb } from "@/db"
import { isDemoUser } from "@/lib/demo"
export async function POST(req: Request): Promise<Response> { export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser() const user = await getCurrentUser()
@ -87,6 +88,9 @@ export async function POST(req: Request): Promise<Response> {
const model = createModelFromId(apiKey, modelId) const model = createModelFromId(apiKey, modelId)
// detect demo mode
const isDemo = isDemoUser(user.id)
const result = streamText({ const result = streamText({
model, model,
system: buildSystemPrompt({ system: buildSystemPrompt({
@ -99,7 +103,7 @@ export async function POST(req: Request): Promise<Response> {
dashboards: dashboardResult.success dashboards: dashboardResult.success
? dashboardResult.data ? dashboardResult.data
: [], : [],
mode: "full", mode: isDemo ? "demo" : "full",
}), }),
messages: await convertToModelMessages( messages: await convertToModelMessages(
body.messages body.messages

View File

@ -290,11 +290,19 @@ async function checkResourceAuthorization(
// Get user for role-based permission check // Get user for role-based permission check
const userRecords = await db.select().from(users).where(eq(users.id, userId)).limit(1) const userRecords = await db.select().from(users).where(eq(users.id, userId)).limit(1)
const user = userRecords[0] const dbUser = userRecords[0]
if (!user) { if (!dbUser) {
return { authorized: false, reason: "User not found" } 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) const action = operationToAction(operation)
// Check role-based permission // Check role-based permission

View File

@ -27,6 +27,8 @@ import { PushNotificationRegistrar } from "@/hooks/use-native-push"
import { DesktopShell } from "@/components/desktop/desktop-shell" import { DesktopShell } from "@/components/desktop/desktop-shell"
import { DesktopOfflineBanner } from "@/components/desktop/offline-banner" import { DesktopOfflineBanner } from "@/components/desktop/offline-banner"
import { VoiceProvider } from "@/components/voice/voice-provider" import { VoiceProvider } from "@/components/voice/voice-provider"
import { DemoBanner } from "@/components/demo/demo-banner"
import { isDemoUser } from "@/lib/demo"
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@ -40,9 +42,12 @@ export default async function DashboardLayout({
getCustomDashboards(), getCustomDashboards(),
]) ])
const user = authUser ? toSidebarUser(authUser) : null const user = authUser ? toSidebarUser(authUser) : null
const activeOrgId = authUser?.organizationId ?? null
const activeOrgName = authUser?.organizationName ?? null
const dashboardList = dashboardResult.success const dashboardList = dashboardResult.success
? dashboardResult.data ? dashboardResult.data
: [] : []
const isDemo = authUser ? isDemoUser(authUser.id) : false
return ( return (
<ChatProvider> <ChatProvider>
@ -51,7 +56,7 @@ export default async function DashboardLayout({
<ProjectListProvider projects={projectList}> <ProjectListProvider projects={projectList}>
<PageActionsProvider> <PageActionsProvider>
<CommandMenuProvider> <CommandMenuProvider>
<BiometricGuard> <BiometricGuard userId={authUser?.id}>
<DesktopShell> <DesktopShell>
<FeedbackWidget> <FeedbackWidget>
<DashboardContextMenu> <DashboardContextMenu>
@ -64,10 +69,18 @@ export default async function DashboardLayout({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<AppSidebar variant="inset" projects={projectList} dashboards={dashboardList} user={user} /> <AppSidebar
variant="inset"
projects={projectList}
dashboards={dashboardList}
user={user}
activeOrgId={activeOrgId}
activeOrgName={activeOrgName}
/>
<SidebarInset className="overflow-hidden"> <SidebarInset className="overflow-hidden">
<DesktopOfflineBanner /> <DesktopOfflineBanner />
<OfflineBanner /> <OfflineBanner />
<DemoBanner isDemo={isDemo} />
<SiteHeader user={user} /> <SiteHeader user={user} />
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
<MainContent> <MainContent>

14
src/app/demo/route.ts Normal file
View File

@ -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")
}

View File

@ -426,7 +426,7 @@ export default function Home(): React.JSX.Element {
style={{ animationDelay: "0.65s" }} style={{ animationDelay: "0.65s" }}
> >
<Link <Link
href="/dashboard" href="/demo"
className={ className={
"group inline-flex " + "group inline-flex " +
"items-center gap-3 " + "items-center gap-3 " +
@ -731,7 +731,7 @@ export default function Home(): React.JSX.Element {
workflows. workflows.
</p> </p>
<Link <Link
href="/dashboard" href="/demo"
className={ className={
"group inline-flex " + "group inline-flex " +
"items-center gap-2 " + "items-center gap-2 " +
@ -1052,7 +1052,7 @@ export default function Home(): React.JSX.Element {
} }
> >
<Link <Link
href="/dashboard" href="/demo"
className={ className={
"group inline-flex " + "group inline-flex " +
"items-center gap-3 " + "items-center gap-3 " +

View File

@ -19,6 +19,7 @@ import { NavFiles } from "@/components/nav-files"
import { NavProjects } from "@/components/nav-projects" import { NavProjects } from "@/components/nav-projects"
import { NavConversations } from "@/components/nav-conversations" import { NavConversations } from "@/components/nav-conversations"
import { NavUser } from "@/components/nav-user" import { NavUser } from "@/components/nav-user"
import { OrgSwitcher } from "@/components/org-switcher"
import { VoicePanel } from "@/components/voice/voice-panel" import { VoicePanel } from "@/components/voice/voice-panel"
// settings is now a page at /dashboard/settings // settings is now a page at /dashboard/settings
import { openFeedbackDialog } from "@/components/feedback-widget" import { openFeedbackDialog } from "@/components/feedback-widget"
@ -143,11 +144,15 @@ export function AppSidebar({
projects = [], projects = [],
dashboards = [], dashboards = [],
user, user,
activeOrgId = null,
activeOrgName = null,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { }: React.ComponentProps<typeof Sidebar> & {
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }> readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }> readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }>
readonly user: SidebarUser | null readonly user: SidebarUser | null
readonly activeOrgId?: string | null
readonly activeOrgName?: string | null
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { channelId } = useVoiceState() const { channelId } = useVoiceState()
@ -181,6 +186,7 @@ export function AppSidebar({
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<OrgSwitcher activeOrgId={activeOrgId} activeOrgName={activeOrgName} />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarNav <SidebarNav

View File

@ -197,7 +197,7 @@ export function createMentionSuggestion(organizationId: string) {
] ]
// fetch users // fetch users
const usersResult = await searchMentionableUsers(query, organizationId) const usersResult = await searchMentionableUsers(query)
const userItems: MentionItem[] = usersResult.success && usersResult.data const userItems: MentionItem[] = usersResult.success && usersResult.data
? usersResult.data.map((user) => ({ ? usersResult.data.map((user) => ({
id: user.id, id: user.id,

View File

@ -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 (
<div
className={cn(
"relative flex items-center justify-center gap-3",
"border-b bg-muted/30 px-4 py-2 text-sm"
)}
>
<span className="text-muted-foreground">
You&rsquo;re exploring a demo workspace
</span>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/login">Log in</Link>
</Button>
<Button asChild size="sm">
<Link href="/signup">Sign up</Link>
</Button>
</div>
<button
onClick={() => setDismissed(true)}
className="absolute right-4 p-1 text-muted-foreground hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ready to build your own workspace?</DialogTitle>
<DialogDescription>
Sign up to create projects, manage schedules, and collaborate with
your team.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button asChild variant="outline">
<Link href="/login">Log in</Link>
</Button>
<Button asChild>
<Link href="/signup">Sign up</Link>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -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 (
<>
<div
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setShowCta(true)
}}
>
{children}
</div>
<DemoCtaDialog open={showCta} onOpenChange={setShowCta} />
</>
)
}

View File

@ -5,13 +5,16 @@ import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth" import { useBiometricAuth } from "@/hooks/use-biometric-auth"
import { Fingerprint, KeyRound } from "lucide-react" import { Fingerprint, KeyRound } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { isDemoUser } from "@/lib/demo"
const BACKGROUND_THRESHOLD_MS = 30_000 const BACKGROUND_THRESHOLD_MS = 30_000
export function BiometricGuard({ export function BiometricGuard({
children, children,
userId,
}: { }: {
readonly children: React.ReactNode readonly children: React.ReactNode
readonly userId?: string
}) { }) {
const native = useNative() const native = useNative()
const { const {
@ -23,6 +26,9 @@ export function BiometricGuard({
markPrompted, markPrompted,
} = useBiometricAuth() } = useBiometricAuth()
// skip biometric for demo users
const isDemo = userId ? isDemoUser(userId) : false
const [locked, setLocked] = useState(false) const [locked, setLocked] = useState(false)
const [showPrompt, setShowPrompt] = useState(false) const [showPrompt, setShowPrompt] = useState(false)
const backgroundedAt = useRef<number | null>(null) const backgroundedAt = useRef<number | null>(null)
@ -104,7 +110,7 @@ export function BiometricGuard({
if (success) setLocked(false) if (success) setLocked(false)
}, [authenticate]) }, [authenticate])
if (!native) return <>{children}</> if (!native || isDemo) return <>{children}</>
return ( return (
<> <>

View File

@ -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<readonly OrgInfo[]>([])
const [isLoading, setIsLoading] = React.useState(false)
React.useEffect(() => {
async function loadOrgs(): Promise<void> {
const result = await getUserOrganizations()
setOrgs(result)
}
void loadOrgs()
}, [])
async function handleOrgSwitch(orgId: string): Promise<void> {
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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{activeOrg?.type === "personal" ? (
<IconUser className="size-4" />
) : (
<IconBuilding className="size-4" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col items-start text-left">
<span className="truncate text-sm font-medium text-sidebar-foreground">
{activeOrgName}
</span>
{activeOrg && (
<span className="truncate text-xs text-sidebar-foreground/60">
{activeOrg.role}
</span>
)}
</div>
<IconChevronDown className="ml-auto size-4 shrink-0 text-sidebar-foreground/60" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="start"
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
<DropdownMenuSeparator />
{orgs.map((org) => {
const isActive = org.id === activeOrgId
const orgIcon =
org.type === "personal" ? IconUser : IconBuilding
return (
<DropdownMenuItem
key={org.id}
onClick={() => void handleOrgSwitch(org.id)}
disabled={isLoading}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
isActive && "bg-accent"
)}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted">
{React.createElement(orgIcon, { className: "size-3" })}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{org.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{org.role}
</span>
</div>
<Badge
variant="secondary"
className="shrink-0 text-[10px] font-normal"
>
{org.type}
</Badge>
{isActive && (
<IconCheck className="ml-1 size-4 shrink-0 text-primary" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@ -182,6 +182,7 @@ export const customers = sqliteTable("customers", {
address: text("address"), address: text("address"),
notes: text("notes"), notes: text("notes"),
netsuiteId: text("netsuite_id"), netsuiteId: text("netsuite_id"),
organizationId: text("organization_id").references(() => organizations.id),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"), updatedAt: text("updated_at"),
}) })
@ -194,6 +195,7 @@ export const vendors = sqliteTable("vendors", {
phone: text("phone"), phone: text("phone"),
address: text("address"), address: text("address"),
netsuiteId: text("netsuite_id"), netsuiteId: text("netsuite_id"),
organizationId: text("organization_id").references(() => organizations.id),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"), updatedAt: text("updated_at"),
}) })

View File

@ -3,7 +3,7 @@ import type { PromptSection } from "@/lib/agent/plugins/types"
// --- types --- // --- types ---
type PromptMode = "full" | "minimal" | "none" type PromptMode = "full" | "minimal" | "none" | "demo"
interface DashboardSummary { interface DashboardSummary {
readonly id: string readonly id: string
@ -231,6 +231,13 @@ const MINIMAL_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"ui", "ui",
]) ])
// categories included in demo mode (read-only subset)
const DEMO_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data",
"navigation",
"ui",
])
// --- derived state --- // --- derived state ---
function extractDescription( function extractDescription(
@ -268,6 +275,9 @@ function computeDerivedState(ctx: PromptContext): DerivedState {
if (mode === "minimal") { if (mode === "minimal") {
return MINIMAL_CATEGORIES.has(t.category) return MINIMAL_CATEGORIES.has(t.category)
} }
if (mode === "demo") {
return DEMO_CATEGORIES.has(t.category)
}
return true return true
}) })
@ -281,6 +291,15 @@ function buildIdentity(mode: PromptMode): ReadonlyArray<string> {
"You are Dr. Slab Diggems, the AI assistant built " + "You are Dr. Slab Diggems, the AI assistant built " +
"into Compass — a construction project management platform." "into Compass — a construction project management platform."
if (mode === "none") return [line] 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."] return [line + " You are reliable, direct, and always ready to help."]
} }
@ -661,6 +680,21 @@ function buildGuidelines(
if (mode === "minimal") return core 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 [ return [
...core, ...core,
"- Tool workflow: data requests -> queryData immediately. " + "- Tool workflow: data requests -> queryData immediately. " +

View File

@ -3,6 +3,7 @@ import { z } from "zod/v4"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { requireOrg } from "@/lib/org-scope"
import { saveMemory, searchMemories } from "@/lib/agent/memory" import { saveMemory, searchMemories } from "@/lib/agent/memory"
import { import {
installSkill as installSkillAction, installSkill as installSkillAction,
@ -23,6 +24,9 @@ import {
} from "@/app/actions/dashboards" } from "@/app/actions/dashboards"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets" import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types" 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({ const queryDataInputSchema = z.object({
queryType: z.enum([ queryType: z.enum([
@ -151,6 +155,12 @@ const recallInputSchema = z.object({
type RecallInput = z.infer<typeof recallInputSchema> type RecallInput = z.infer<typeof recallInputSchema>
async function executeQueryData(input: QueryDataInput) { 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 { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const cap = input.limit ?? 20 const cap = input.limit ?? 20
@ -159,12 +169,13 @@ async function executeQueryData(input: QueryDataInput) {
case "customers": { case "customers": {
const rows = await db.query.customers.findMany({ const rows = await db.query.customers.findMany({
limit: cap, limit: cap,
...(input.search where: (c, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
? { const conditions = [eqFunc(c.organizationId, orgId)]
where: (c, { like }) => if (input.search) {
like(c.name, `%${input.search}%`), conditions.push(likeFunc(c.name, `%${input.search}%`))
} }
: {}), return conditions.length > 1 ? andFunc(...conditions) : conditions[0]
},
}) })
return { data: rows, count: rows.length } return { data: rows, count: rows.length }
} }
@ -172,12 +183,13 @@ async function executeQueryData(input: QueryDataInput) {
case "vendors": { case "vendors": {
const rows = await db.query.vendors.findMany({ const rows = await db.query.vendors.findMany({
limit: cap, limit: cap,
...(input.search where: (v, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
? { const conditions = [eqFunc(v.organizationId, orgId)]
where: (v, { like }) => if (input.search) {
like(v.name, `%${input.search}%`), conditions.push(likeFunc(v.name, `%${input.search}%`))
} }
: {}), return conditions.length > 1 ? andFunc(...conditions) : conditions[0]
},
}) })
return { data: rows, count: rows.length } return { data: rows, count: rows.length }
} }
@ -185,40 +197,103 @@ async function executeQueryData(input: QueryDataInput) {
case "projects": { case "projects": {
const rows = await db.query.projects.findMany({ const rows = await db.query.projects.findMany({
limit: cap, limit: cap,
...(input.search where: (p, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
? { const conditions = [eqFunc(p.organizationId, orgId)]
where: (p, { like }) => if (input.search) {
like(p.name, `%${input.search}%`), conditions.push(likeFunc(p.name, `%${input.search}%`))
} }
: {}), return conditions.length > 1 ? andFunc(...conditions) : conditions[0]
},
}) })
return { data: rows, count: rows.length } return { data: rows, count: rows.length }
} }
case "invoices": { case "invoices": {
const rows = await db.query.invoices.findMany({ // join through projects to filter by org
limit: cap, 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 } return { data: rows, count: rows.length }
} }
case "vendor_bills": { case "vendor_bills": {
const rows = await db.query.vendorBills.findMany({ // join through projects to filter by org
limit: cap, 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 } return { data: rows, count: rows.length }
} }
case "schedule_tasks": { case "schedule_tasks": {
const rows = await db.query.scheduleTasks.findMany({ // join through projects to filter by org
limit: cap, const whereConditions = [eq(projects.organizationId, orgId)]
...(input.search if (input.search) {
? { whereConditions.push(like(scheduleTasks.title, `%${input.search}%`))
where: (t, { like }) => }
like(t.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 } return { data: rows, count: rows.length }
} }
@ -227,7 +302,8 @@ async function executeQueryData(input: QueryDataInput) {
return { error: "id required for detail query" } return { error: "id required for detail query" }
} }
const row = await db.query.projects.findFirst({ 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" } return row ? { data: row } : { error: "not found" }
} }
@ -237,7 +313,8 @@ async function executeQueryData(input: QueryDataInput) {
return { error: "id required for detail query" } return { error: "id required for detail query" }
} }
const row = await db.query.customers.findFirst({ 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" } return row ? { data: row } : { error: "not found" }
} }
@ -247,7 +324,8 @@ async function executeQueryData(input: QueryDataInput) {
return { error: "id required for detail query" } return { error: "id required for detail query" }
} }
const row = await db.query.vendors.findFirst({ 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" } return row ? { data: row } : { error: "not found" }
} }

View File

@ -1,9 +1,11 @@
import { withAuth, signOut } from "@workos-inc/authkit-nextjs" import { withAuth, signOut } from "@workos-inc/authkit-nextjs"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { users } from "@/db/schema" import { users, organizations, organizationMembers } from "@/db/schema"
import type { User } from "@/db/schema" import type { User } from "@/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { cookies } from "next/headers"
import { DEMO_USER } from "@/lib/demo"
export type AuthUser = { export type AuthUser = {
readonly id: string readonly id: string
@ -16,6 +18,9 @@ export type AuthUser = {
readonly googleEmail: string | null readonly googleEmail: string | null
readonly isActive: boolean readonly isActive: boolean
readonly lastLoginAt: string | null readonly lastLoginAt: string | null
readonly organizationId: string | null
readonly organizationName: string | null
readonly organizationType: string | null
readonly createdAt: string readonly createdAt: string
readonly updatedAt: string readonly updatedAt: string
} }
@ -48,6 +53,15 @@ export function toSidebarUser(user: AuthUser): SidebarUser {
export async function getCurrentUser(): Promise<AuthUser | null> { export async function getCurrentUser(): Promise<AuthUser | null> {
try { 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 // check if workos is configured
const isWorkOSConfigured = const isWorkOSConfigured =
process.env.WORKOS_API_KEY && process.env.WORKOS_API_KEY &&
@ -67,6 +81,9 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
googleEmail: null, googleEmail: null,
isActive: true, isActive: true,
lastLoginAt: new Date().toISOString(), lastLoginAt: new Date().toISOString(),
organizationId: "hps-org-001",
organizationName: "HPS",
organizationType: "internal",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
@ -102,6 +119,36 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
.where(eq(users.id, workosUser.id)) .where(eq(users.id, workosUser.id))
.run() .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 { return {
id: dbUser.id, id: dbUser.id,
email: dbUser.email, email: dbUser.email,
@ -113,6 +160,9 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
googleEmail: dbUser.googleEmail ?? null, googleEmail: dbUser.googleEmail ?? null,
isActive: dbUser.isActive, isActive: dbUser.isActive,
lastLoginAt: now, lastLoginAt: now,
organizationId: activeOrg?.orgId ?? null,
organizationName: activeOrg?.orgName ?? null,
organizationType: activeOrg?.orgType ?? null,
createdAt: dbUser.createdAt, createdAt: dbUser.createdAt,
updatedAt: dbUser.updatedAt, updatedAt: dbUser.updatedAt,
} }
@ -166,6 +216,50 @@ export async function ensureUserExists(workosUser: {
await db.insert(users).values(newUser).run() 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 return newUser as User
} }

30
src/lib/demo.ts Normal file
View File

@ -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
}

8
src/lib/org-scope.ts Normal file
View File

@ -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
}

View File

@ -10,6 +10,7 @@ const publicPaths = [
"/verify-email", "/verify-email",
"/invite", "/invite",
"/callback", "/callback",
"/demo",
] ]
// bridge routes use their own API key auth // bridge routes use their own API key auth
@ -40,6 +41,12 @@ export default async function middleware(request: NextRequest) {
return handleAuthkitHeaders(request, headers) 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 // redirect unauthenticated users to our custom login page
if (!session.user) { if (!session.user) {
const loginUrl = new URL("/login", request.url) const loginUrl = new URL("/login", request.url)

View File

@ -27,5 +27,5 @@
] ]
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "references", "packages"] "exclude": ["node_modules", "references", "packages", "scripts"]
} }