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>
204 lines
5.7 KiB
TypeScript
Executable File
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",
|
|
}
|
|
}
|
|
}
|