compassmock/src/app/actions/payments.ts
Nicholai ad2f0c0b9c
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>
2026-02-15 22:05:12 -07:00

204 lines
5.7 KiB
TypeScript
Executable File

"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq, and } from "drizzle-orm"
import { getDb } from "@/db"
import { payments, type NewPayment } from "@/db/schema-netsuite"
import { projects } from "@/db/schema"
import { requireAuth } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
import { requireOrg } from "@/lib/org-scope"
import { isDemoUser } from "@/lib/demo"
export async function getPayments() {
const user = await requireAuth()
requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// join through projects to filter by org
return db
.select({
id: payments.id,
netsuiteId: payments.netsuiteId,
customerId: payments.customerId,
vendorId: payments.vendorId,
projectId: payments.projectId,
paymentType: payments.paymentType,
amount: payments.amount,
paymentDate: payments.paymentDate,
paymentMethod: payments.paymentMethod,
referenceNumber: payments.referenceNumber,
memo: payments.memo,
createdAt: payments.createdAt,
updatedAt: payments.updatedAt,
})
.from(payments)
.innerJoin(projects, eq(payments.projectId, projects.id))
.where(eq(projects.organizationId, orgId))
}
export async function getPayment(id: string) {
const user = await requireAuth()
requirePermission(user, "finance", "read")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// join through project to verify org
const rows = await db
.select({
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(and(eq(payments.id, id), eq(projects.organizationId, orgId)))
.limit(1)
return rows[0] ?? null
}
export async function createPayment(
data: Omit<NewPayment, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "create")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// verify project belongs to org if provided
if (data.projectId) {
const [project] = await db
.select()
.from(projects)
.where(and(eq(projects.id, data.projectId), eq(projects.organizationId, orgId)))
.limit(1)
if (!project) {
return { success: false, error: "Project not found or access denied" }
}
}
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(payments).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/financials")
return { success: true, id }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to create payment",
}
}
}
export async function updatePayment(
id: string,
data: Partial<NewPayment>
) {
try {
const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "update")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// verify payment belongs to org via project
const [existing] = await db
.select({ projectId: payments.projectId })
.from(payments)
.innerJoin(projects, eq(payments.projectId, projects.id))
.where(and(eq(payments.id, id), eq(projects.organizationId, orgId)))
.limit(1)
if (!existing) {
return { success: false, error: "Payment not found or access denied" }
}
await db
.update(payments)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(payments.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to update payment",
}
}
}
export async function deletePayment(id: string) {
try {
const user = await requireAuth()
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
requirePermission(user, "finance", "delete")
const orgId = requireOrg(user)
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// verify payment belongs to org via project
const [existing] = await db
.select({ projectId: payments.projectId })
.from(payments)
.innerJoin(projects, eq(payments.projectId, projects.id))
.where(and(eq(payments.id, id), eq(projects.organizationId, orgId)))
.limit(1)
if (!existing) {
return { success: false, error: "Payment not found or access denied" }
}
await db.delete(payments).where(eq(payments.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to delete payment",
}
}
}