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:
parent
49518d3633
commit
ad2f0c0b9c
@ -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
|
||||||
|
|||||||
328
docs/architecture/multi-tenancy.md
Normal file
328
docs/architecture/multi-tenancy.md
Normal 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.
|
||||||
2
drizzle/0026_easy_professor_monster.sql
Normal file
2
drizzle/0026_easy_professor_monster.sql
Normal 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);
|
||||||
5023
drizzle/meta/0026_snapshot.json
Normal file
5023
drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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
142
scripts/migrate-to-orgs.ts
Normal 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
284
scripts/seed-demo.ts
Normal 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)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -442,19 +442,12 @@ 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.conversationId,
|
|
||||||
conversationId
|
|
||||||
),
|
|
||||||
eq(agentUsage.userId, user.id)
|
eq(agentUsage.userId, user.id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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 }) =>
|
||||||
|
and(
|
||||||
|
eq(c.organizationId, orgId),
|
||||||
like(c.name, `%${q.search}%`),
|
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 }) =>
|
||||||
|
and(
|
||||||
|
eq(v.organizationId, orgId),
|
||||||
like(v.name, `%${q.search}%`),
|
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 }) =>
|
||||||
|
and(
|
||||||
|
eq(p.organizationId, orgId),
|
||||||
like(p.name, `%${q.search}%`),
|
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
|
||||||
|
.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,
|
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
|
||||||
|
.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,
|
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
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.organizationId, orgId))
|
||||||
|
const projectIds = orgProjects.map((p) => p.id)
|
||||||
|
const rows =
|
||||||
|
projectIds.length > 0
|
||||||
|
? await db.query.scheduleTasks.findMany({
|
||||||
limit: cap,
|
limit: cap,
|
||||||
...(q.search
|
...(q.search
|
||||||
? {
|
? {
|
||||||
where: (t, { like }) =>
|
where: (t, { like, inArray, and }) =>
|
||||||
|
and(
|
||||||
|
inArray(t.projectId, projectIds),
|
||||||
like(t.title, `%${q.search}%`),
|
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,10 +356,14 @@ 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!),
|
||||||
})
|
})
|
||||||
|
if (row && row.organizationId !== orgId) {
|
||||||
|
dataContext[q.key] = { error: "not found" }
|
||||||
|
} else {
|
||||||
dataContext[q.key] = row
|
dataContext[q.key] = row
|
||||||
? { data: row }
|
? { data: row }
|
||||||
: { error: "not found" }
|
: { error: "not found" }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "customer_detail": {
|
case "customer_detail": {
|
||||||
@ -305,10 +371,14 @@ 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!),
|
||||||
})
|
})
|
||||||
|
if (row && row.organizationId !== orgId) {
|
||||||
|
dataContext[q.key] = { error: "not found" }
|
||||||
|
} else {
|
||||||
dataContext[q.key] = row
|
dataContext[q.key] = row
|
||||||
? { data: row }
|
? { data: row }
|
||||||
: { error: "not found" }
|
: { error: "not found" }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vendor_detail": {
|
case "vendor_detail": {
|
||||||
@ -316,10 +386,14 @@ 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!),
|
||||||
})
|
})
|
||||||
|
if (row && row.organizationId !== orgId) {
|
||||||
|
dataContext[q.key] = { error: "not found" }
|
||||||
|
} else {
|
||||||
dataContext[q.key] = row
|
dataContext[q.key] = row
|
||||||
? { data: row }
|
? { data: row }
|
||||||
: { error: "not found" }
|
: { error: "not found" }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() })
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
14
src/app/demo/route.ts
Normal 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")
|
||||||
|
}
|
||||||
@ -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 " +
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
45
src/components/demo/demo-banner.tsx
Normal file
45
src/components/demo/demo-banner.tsx
Normal 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’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/demo/demo-cta-dialog.tsx
Normal file
40
src/components/demo/demo-cta-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/demo/demo-gate.tsx
Normal file
31
src/components/demo/demo-gate.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
161
src/components/org-switcher.tsx
Normal file
161
src/components/org-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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"),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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. " +
|
||||||
|
|||||||
@ -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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
30
src/lib/demo.ts
Normal 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
8
src/lib/org-scope.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user