diff --git a/apps/api/src/controllers/crm-custom-fields.controller.ts b/apps/api/src/controllers/crm-custom-fields.controller.ts new file mode 100644 index 0000000..74433d5 --- /dev/null +++ b/apps/api/src/controllers/crm-custom-fields.controller.ts @@ -0,0 +1,165 @@ +import type { Context } from "hono"; +import type { + CreateCustomFieldDefinitionInput, + CustomFieldEntityType, + UpdateCustomFieldDefinitionInput, +} from "@workspace/validators/schemas/crm"; +import { and, asc, eq } from "drizzle-orm"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import { db } from "@/db/client.js"; +import { crmCustomFieldDefinitions } from "@/db/schema/index.js"; +import { sendSuccess } from "@/lib/api-response.js"; +import { AppError } from "@/lib/app-error.js"; +import { getSessionWorkspaceId } from "@/lib/workspace.js"; +import { + assertLabelUnique, + buildUpdatedSelectOptions, + clearFieldValues, + clearOptionValue, + normalizeOptions, +} from "@/utils/crm-custom-fields.js"; + +export async function listCustomFieldDefinitions(c: Context, entityType: CustomFieldEntityType) { + const workspaceId = getSessionWorkspaceId(c); + + const customFields = await db + .select() + .from(crmCustomFieldDefinitions) + .where( + and( + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + ), + ) + .orderBy(asc(crmCustomFieldDefinitions.createdAt)); + + return sendSuccess(c, { customFields }, STATUS_CODES.OK); +} + +export async function createCustomFieldDefinition( + c: Context, + entityType: CustomFieldEntityType, + payload: CreateCustomFieldDefinitionInput, +) { + const workspaceId = getSessionWorkspaceId(c); + + const label = payload.label.trim(); + await assertLabelUnique(workspaceId, entityType, label); + + const options = payload.type === "select" ? normalizeOptions(payload.options) : []; + + const [customField] = await db + .insert(crmCustomFieldDefinitions) + .values({ + workspaceId, + entityType, + label, + fieldType: payload.type, + options, + }) + .returning(); + + return sendSuccess(c, { customField }, STATUS_CODES.CREATED); +} + +export async function updateCustomFieldDefinition( + c: Context, + entityType: CustomFieldEntityType, + id: string, + payload: UpdateCustomFieldDefinitionInput, +) { + const workspaceId = getSessionWorkspaceId(c); + + const existing = await db.query.crmCustomFieldDefinitions.findFirst({ + where: and( + eq(crmCustomFieldDefinitions.id, id), + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + ), + }); + + if (!existing) { + throw new AppError("Custom field definition not found", STATUS_CODES.NOT_FOUND); + } + + const updates: { + label?: string; + options?: { id: string; label: string }[]; + updatedAt: Date; + } = { + updatedAt: new Date(), + }; + + if (payload.label !== undefined) { + const label = payload.label.trim(); + await assertLabelUnique(workspaceId, entityType, label, id); + updates.label = label; + } + + let removedOptionIds: string[] = []; + if (payload.options !== undefined) { + if (existing.fieldType !== "select") { + throw new AppError( + "Only select custom fields can update options", + STATUS_CODES.UNPROCESSABLE_ENTITY, + ); + } + + const selectOptionsResult = buildUpdatedSelectOptions(existing.options ?? [], payload.options); + removedOptionIds = selectOptionsResult.removedOptionIds; + updates.options = selectOptionsResult.options; + } + + const customField = await db.transaction(async (tx) => { + const [updated] = await tx + .update(crmCustomFieldDefinitions) + .set(updates) + .where( + and( + eq(crmCustomFieldDefinitions.id, id), + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + ), + ) + .returning(); + + for (const optionId of removedOptionIds) { + await clearOptionValue(entityType, workspaceId, id, optionId, tx); + } + + return updated; + }); + + return sendSuccess(c, { customField }, STATUS_CODES.OK); +} + +export async function deleteCustomFieldDefinition( + c: Context, + entityType: CustomFieldEntityType, + id: string, +) { + const workspaceId = getSessionWorkspaceId(c); + + const customField = await db.transaction(async (tx) => { + const [deleted] = await tx + .delete(crmCustomFieldDefinitions) + .where( + and( + eq(crmCustomFieldDefinitions.id, id), + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + ), + ) + .returning(); + + if (!deleted) { + throw new AppError("Custom field definition not found", STATUS_CODES.NOT_FOUND); + } + + await clearFieldValues(entityType, workspaceId, id, tx); + + return deleted; + }); + + return sendSuccess(c, { customField }, STATUS_CODES.OK); +} diff --git a/apps/api/src/controllers/deals.controller.ts b/apps/api/src/controllers/deals.controller.ts index c09d757..89dd3f5 100644 --- a/apps/api/src/controllers/deals.controller.ts +++ b/apps/api/src/controllers/deals.controller.ts @@ -2,7 +2,7 @@ import type { Context } from "hono"; import type { CreateDeal, ListDealsQuery, UpdateDeal } from "@workspace/validators/schemas/crm"; import { and, asc, count, desc, eq, ilike, or } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { deals, orgs, people, user } from "@/db/schema/index.js"; +import { deals, org, people, user } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; @@ -58,12 +58,12 @@ export async function listDeals(c: Context, query: ListDealsQuery) { closeDate: deals.closeDate, createdAt: deals.createdAt, updatedAt: deals.updatedAt, - orgName: orgs.name, + orgName: org.name, personName: people.name, ownerName: user.name, }) .from(deals) - .leftJoin(orgs, eq(deals.orgId, orgs.id)) + .leftJoin(org, eq(deals.orgId, org.id)) .leftJoin(people, eq(deals.personId, people.id)) .leftJoin(user, eq(deals.ownerId, user.id)) .where(whereClause) @@ -79,12 +79,7 @@ export async function listDeals(c: Context, query: ListDealsQuery) { return sendSuccess( c, { - deals: results.map((deal) => ({ - ...deal, - orgName: deal.orgName ?? null, - personName: deal.personName ?? null, - ownerName: deal.ownerName ?? null, - })), + deals: results, meta: { page, pageSize, diff --git a/apps/api/src/controllers/orgs.controller.ts b/apps/api/src/controllers/org.controller.ts similarity index 62% rename from apps/api/src/controllers/orgs.controller.ts rename to apps/api/src/controllers/org.controller.ts index 138fc08..f588fb7 100644 --- a/apps/api/src/controllers/orgs.controller.ts +++ b/apps/api/src/controllers/org.controller.ts @@ -7,7 +7,7 @@ import type { } from "@workspace/validators/schemas/crm"; import { and, asc, count, desc, eq, ilike, inArray, or } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { orgs, people } from "@/db/schema/index.js"; +import { org, people } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; @@ -17,18 +17,18 @@ export async function listOrgs(c: Context, query: ListOrgsQuery) { const workspaceId = getSessionWorkspaceId(c); const { page, pageSize, sortOrder, sortBy, search, industry, size } = query; - const conditions = [eq(orgs.workspaceId, workspaceId)]; - if (industry) conditions.push(eq(orgs.industry, industry)); - if (size) conditions.push(eq(orgs.size, size)); + const conditions = [eq(org.workspaceId, workspaceId)]; + if (industry) conditions.push(eq(org.industry, industry)); + if (size) conditions.push(eq(org.size, size)); if (search) { const searchTerm = `%${search}%`; conditions.push( or( - ilike(orgs.name, searchTerm), - ilike(orgs.domain, searchTerm), - ilike(orgs.industry, searchTerm), - ilike(orgs.size, searchTerm), - ilike(orgs.location, searchTerm), + ilike(org.name, searchTerm), + ilike(org.domain, searchTerm), + ilike(org.industry, searchTerm), + ilike(org.size, searchTerm), + ilike(org.location, searchTerm), )!, ); } @@ -39,32 +39,32 @@ export async function listOrgs(c: Context, query: ListOrgsQuery) { const orderBy = (() => { switch (sortBy) { case "domain": - return direction(orgs.domain); + return direction(org.domain); case "industry": - return direction(orgs.industry); + return direction(org.industry); case "size": - return direction(orgs.size); + return direction(org.size); case "location": - return direction(orgs.location); + return direction(org.location); case "createdAt": - return direction(orgs.createdAt); + return direction(org.createdAt); case "updatedAt": - return direction(orgs.updatedAt); + return direction(org.updatedAt); case "name": default: - return direction(orgs.name); + return direction(org.name); } })(); const [rows, totalCountResult, peopleCounts] = await Promise.all([ db .select() - .from(orgs) + .from(org) .where(whereClause) .orderBy(orderBy) .limit(pageSize) .offset((page - 1) * pageSize), - db.select({ totalCount: count() }).from(orgs).where(whereClause), + db.select({ totalCount: count() }).from(org).where(whereClause), db .select({ orgId: people.orgId, count: count() }) .from(people) @@ -79,7 +79,7 @@ export async function listOrgs(c: Context, query: ListOrgsQuery) { return sendSuccess( c, { - orgs: rows.map((org) => ({ ...org, peopleCount: peopleCountMap.get(org.id) ?? 0 })), + org: rows.map((o) => ({ ...o, peopleCount: peopleCountMap.get(o.id) ?? 0 })), meta: { page, pageSize, totalCount, totalPages }, }, STATUS_CODES.OK, @@ -88,8 +88,8 @@ export async function listOrgs(c: Context, query: ListOrgsQuery) { export async function getOrg(c: Context, id: string) { const workspaceId = getSessionWorkspaceId(c); - const org = await db.query.orgs.findFirst({ - where: and(eq(orgs.id, id), eq(orgs.workspaceId, workspaceId)), + const result = await db.query.org.findFirst({ + where: and(eq(org.id, id), eq(org.workspaceId, workspaceId)), with: { people: { columns: { @@ -100,64 +100,64 @@ export async function getOrg(c: Context, id: string) { }, }); - if (!org) { + if (!result) { throw new AppError("Organization not found", STATUS_CODES.NOT_FOUND); } - return sendSuccess(c, { org }, STATUS_CODES.OK); + return sendSuccess(c, { org: result }, STATUS_CODES.OK); } export async function createOrg(c: Context, payload: CreateOrg) { const workspaceId = getSessionWorkspaceId(c); - const [org] = await db - .insert(orgs) + const [result] = await db + .insert(org) .values({ ...payload, workspaceId, }) .returning(); - return sendSuccess(c, { org }, STATUS_CODES.CREATED); + return sendSuccess(c, { org: result }, STATUS_CODES.CREATED); } export async function updateOrg(c: Context, id: string, payload: UpdateOrg) { const workspaceId = getSessionWorkspaceId(c); - const [org] = await db - .update(orgs) + const [result] = await db + .update(org) .set({ ...payload, updatedAt: new Date(), }) - .where(and(eq(orgs.id, id), eq(orgs.workspaceId, workspaceId))) + .where(and(eq(org.id, id), eq(org.workspaceId, workspaceId))) .returning(); - if (!org) { + if (!result) { throw new AppError("Organization not found", STATUS_CODES.NOT_FOUND); } - return sendSuccess(c, { org }, STATUS_CODES.OK); + return sendSuccess(c, { org: result }, STATUS_CODES.OK); } export async function deleteOrg(c: Context, id: string) { const workspaceId = getSessionWorkspaceId(c); - const [org] = await db - .delete(orgs) - .where(and(eq(orgs.id, id), eq(orgs.workspaceId, workspaceId))) + const [result] = await db + .delete(org) + .where(and(eq(org.id, id), eq(org.workspaceId, workspaceId))) .returning(); - if (!org) { + if (!result) { throw new AppError("Organization not found", STATUS_CODES.NOT_FOUND); } - return sendSuccess(c, { org }, STATUS_CODES.OK); + return sendSuccess(c, { org: result }, STATUS_CODES.OK); } export async function bulkDeleteOrgs(c: Context, payload: BulkDeleteInput) { const workspaceId = getSessionWorkspaceId(c); - const deletedOrgs = await db - .delete(orgs) - .where(and(eq(orgs.workspaceId, workspaceId), inArray(orgs.id, payload.ids))) - .returning({ id: orgs.id }); + const deleted = await db + .delete(org) + .where(and(eq(org.workspaceId, workspaceId), inArray(org.id, payload.ids))) + .returning({ id: org.id }); - return sendSuccess(c, { deleted: deletedOrgs.length }, STATUS_CODES.OK); + return sendSuccess(c, { deleted: deleted.length }, STATUS_CODES.OK); } diff --git a/apps/api/src/controllers/people.controller.ts b/apps/api/src/controllers/people.controller.ts index cd1842c..a4c89b7 100644 --- a/apps/api/src/controllers/people.controller.ts +++ b/apps/api/src/controllers/people.controller.ts @@ -7,7 +7,7 @@ import type { } from "@workspace/validators/schemas/crm"; import { and, asc, count, desc, eq, ilike, or, inArray } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { orgs, people, user } from "@/db/schema/index.js"; +import { org, people, user } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; @@ -69,11 +69,11 @@ export async function listPeople(c: Context, query: ListPeopleQuery) { customFields: people.customFields, createdAt: people.createdAt, updatedAt: people.updatedAt, - orgName: orgs.name, + orgName: org.name, ownerName: user.name, }) .from(people) - .leftJoin(orgs, eq(people.orgId, orgs.id)) + .leftJoin(org, eq(people.orgId, org.id)) .leftJoin(user, eq(people.ownerId, user.id)) .where(whereClause) .orderBy(orderBy) diff --git a/apps/api/src/db/drizzle/0000_unknown_red_shift.sql b/apps/api/src/db/drizzle/0000_yellow_tomas.sql similarity index 74% rename from apps/api/src/db/drizzle/0000_unknown_red_shift.sql rename to apps/api/src/db/drizzle/0000_yellow_tomas.sql index 3fcff31..d21b3d2 100644 --- a/apps/api/src/db/drizzle/0000_unknown_red_shift.sql +++ b/apps/api/src/db/drizzle/0000_yellow_tomas.sql @@ -1,3 +1,11 @@ +CREATE TYPE "public"."crm_custom_field_entity_type" AS ENUM('people', 'org');--> statement-breakpoint +CREATE TYPE "public"."crm_custom_field_type" AS ENUM('text', 'number', 'select', 'dateTime');--> statement-breakpoint +CREATE TYPE "public"."deal_stage" AS ENUM('new', 'contacted', 'demo', 'proposal', 'won', 'lost');--> statement-breakpoint +CREATE TYPE "public"."people_source" AS ENUM('manual', 'csv', 'api');--> statement-breakpoint +CREATE TYPE "public"."people_status" AS ENUM('lead', 'prospect', 'qualified', 'customer', 'churned');--> statement-breakpoint +CREATE TYPE "public"."workspace_invite_role" AS ENUM('admin', 'member');--> statement-breakpoint +CREATE TYPE "public"."workspace_invite_status" AS ENUM('pending', 'accepted', 'rejected', 'canceled');--> statement-breakpoint +CREATE TYPE "public"."workspace_role" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint CREATE TABLE "account" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_id" uuid NOT NULL, @@ -47,6 +55,18 @@ CREATE TABLE "verification" ( "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint +CREATE TABLE "crm_custom_field_definitions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "entity_type" "crm_custom_field_entity_type" NOT NULL, + "field_type" "crm_custom_field_type" NOT NULL, + "label" varchar(255) NOT NULL, + "options" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "crm_custom_field_definitions_workspace_entity_label_unique" UNIQUE("workspace_id","entity_type","label") +); +--> statement-breakpoint CREATE TABLE "deals" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "workspace_id" uuid NOT NULL, @@ -56,13 +76,13 @@ CREATE TABLE "deals" ( "title" varchar(255) NOT NULL, "value" text, "currency" varchar(3) DEFAULT 'USD' NOT NULL, - "stage" text DEFAULT 'new' NOT NULL, + "stage" "deal_stage" DEFAULT 'new' NOT NULL, "close_date" timestamp, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE "orgs" ( +CREATE TABLE "org" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "workspace_id" uuid NOT NULL, "name" varchar(255) NOT NULL, @@ -73,7 +93,7 @@ CREATE TABLE "orgs" ( "custom_fields" jsonb DEFAULT '{}'::jsonb, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "orgs_workspace_name_unique" UNIQUE("workspace_id","name") + CONSTRAINT "org_workspace_name_unique" UNIQUE("workspace_id","name") ); --> statement-breakpoint CREATE TABLE "people" ( @@ -86,8 +106,8 @@ CREATE TABLE "people" ( "phone" varchar(50), "job_title" varchar(255), "linkedin_url" varchar(500), - "status" text DEFAULT 'lead' NOT NULL, - "source" text DEFAULT 'manual' NOT NULL, + "status" "people_status" DEFAULT 'lead' NOT NULL, + "source" "people_source" DEFAULT 'manual' NOT NULL, "last_contacted_at" timestamp, "custom_fields" jsonb DEFAULT '{}'::jsonb, "created_at" timestamp DEFAULT now() NOT NULL, @@ -99,9 +119,9 @@ CREATE TABLE "workspace_invites" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "workspace_id" uuid NOT NULL, "email" varchar(255) NOT NULL, - "role" text DEFAULT 'member' NOT NULL, + "role" "workspace_invite_role" DEFAULT 'member' NOT NULL, "token" varchar(255), - "status" text DEFAULT 'pending' NOT NULL, + "status" "workspace_invite_status" DEFAULT 'pending' NOT NULL, "expires_at" timestamp NOT NULL, "created_by" uuid, "created_at" timestamp DEFAULT now() NOT NULL, @@ -113,7 +133,7 @@ CREATE TABLE "workspace_members" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "workspace_id" uuid NOT NULL, "user_id" uuid NOT NULL, - "role" text DEFAULT 'member' NOT NULL, + "role" "workspace_role" DEFAULT 'member' NOT NULL, "joined_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "workspace_members_workspace_user_unique" UNIQUE("workspace_id","user_id") ); @@ -132,13 +152,14 @@ CREATE TABLE "workspaces" ( --> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "crm_custom_field_definitions" ADD CONSTRAINT "crm_custom_field_definitions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "deals" ADD CONSTRAINT "deals_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "deals" ADD CONSTRAINT "deals_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "deals" ADD CONSTRAINT "deals_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_org_id_org_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."org"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "deals" ADD CONSTRAINT "deals_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "orgs" ADD CONSTRAINT "orgs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "org" ADD CONSTRAINT "org_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "people" ADD CONSTRAINT "people_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "people" ADD CONSTRAINT "people_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "people" ADD CONSTRAINT "people_org_id_org_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."org"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "people" ADD CONSTRAINT "people_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "workspace_invites" ADD CONSTRAINT "workspace_invites_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "workspace_invites" ADD CONSTRAINT "workspace_invites_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint @@ -150,13 +171,15 @@ CREATE INDEX "account_provider_idx" ON "account" USING btree ("provider_id","acc CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "user_email_idx" ON "user" USING btree ("email");--> statement-breakpoint CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint +CREATE INDEX "crm_custom_field_definitions_workspace_entity_idx" ON "crm_custom_field_definitions" USING btree ("workspace_id","entity_type");--> statement-breakpoint +CREATE INDEX "crm_custom_field_definitions_workspace_id_idx" ON "crm_custom_field_definitions" USING btree ("workspace_id");--> statement-breakpoint CREATE INDEX "deals_workspace_id_idx" ON "deals" USING btree ("workspace_id");--> statement-breakpoint CREATE INDEX "deals_person_id_idx" ON "deals" USING btree ("person_id");--> statement-breakpoint CREATE INDEX "deals_org_id_idx" ON "deals" USING btree ("org_id");--> statement-breakpoint CREATE INDEX "deals_owner_id_idx" ON "deals" USING btree ("owner_id");--> statement-breakpoint CREATE INDEX "deals_stage_idx" ON "deals" USING btree ("stage");--> statement-breakpoint CREATE INDEX "deals_close_date_idx" ON "deals" USING btree ("close_date");--> statement-breakpoint -CREATE INDEX "orgs_workspace_id_idx" ON "orgs" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "org_workspace_id_idx" ON "org" USING btree ("workspace_id");--> statement-breakpoint CREATE INDEX "people_workspace_id_idx" ON "people" USING btree ("workspace_id");--> statement-breakpoint CREATE INDEX "people_org_id_idx" ON "people" USING btree ("org_id");--> statement-breakpoint CREATE INDEX "people_owner_id_idx" ON "people" USING btree ("owner_id");--> statement-breakpoint diff --git a/apps/api/src/db/drizzle/0001_workspace_role_validation.sql b/apps/api/src/db/drizzle/0001_workspace_role_validation.sql deleted file mode 100644 index 7d54a5d..0000000 --- a/apps/api/src/db/drizzle/0001_workspace_role_validation.sql +++ /dev/null @@ -1,50 +0,0 @@ -UPDATE "workspace_members" -SET "role" = 'member' -WHERE "role" IS NULL - OR "role" NOT IN ('owner', 'admin', 'member'); - -UPDATE "workspace_invites" -SET "role" = 'member' -WHERE "role" IS NULL - OR "role" NOT IN ('admin', 'member'); - -INSERT INTO "workspace_members" ("workspace_id", "user_id", "role", "joined_at") -SELECT w."id", w."owner_id", 'owner', NOW() -FROM "workspaces" w -LEFT JOIN "workspace_members" wm - ON wm."workspace_id" = w."id" - AND wm."user_id" = w."owner_id" -WHERE wm."id" IS NULL; - -UPDATE "workspace_members" wm -SET "role" = 'owner' -FROM "workspaces" w -WHERE wm."workspace_id" = w."id" - AND wm."user_id" = w."owner_id" - AND wm."role" <> 'owner'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'workspace_members_role_check' - ) THEN - ALTER TABLE "workspace_members" - ADD CONSTRAINT "workspace_members_role_check" - CHECK ("role" IN ('owner', 'admin', 'member')); - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'workspace_invites_role_check' - ) THEN - ALTER TABLE "workspace_invites" - ADD CONSTRAINT "workspace_invites_role_check" - CHECK ("role" IN ('admin', 'member')); - END IF; -END $$; diff --git a/apps/api/src/db/drizzle/0002_smart_stepford_cuckoos.sql b/apps/api/src/db/drizzle/0002_smart_stepford_cuckoos.sql deleted file mode 100644 index bc1a6ff..0000000 --- a/apps/api/src/db/drizzle/0002_smart_stepford_cuckoos.sql +++ /dev/null @@ -1,38 +0,0 @@ -ALTER TABLE "workspace_members" DROP CONSTRAINT IF EXISTS "workspace_members_role_check";--> statement-breakpoint -ALTER TABLE "workspace_invites" DROP CONSTRAINT IF EXISTS "workspace_invites_role_check";--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."deal_stage" AS ENUM('new', 'contacted', 'demo', 'proposal', 'won', 'lost'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."people_source" AS ENUM('manual', 'csv', 'api'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."people_status" AS ENUM('lead', 'prospect', 'qualified', 'customer', 'churned'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."workspace_invite_role" AS ENUM('admin', 'member'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."workspace_invite_status" AS ENUM('pending', 'accepted', 'rejected', 'canceled'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN - CREATE TYPE "public"."workspace_role" AS ENUM('owner', 'admin', 'member'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -ALTER TABLE "deals" ALTER COLUMN "stage" SET DEFAULT 'new'::"public"."deal_stage";--> statement-breakpoint -ALTER TABLE "deals" ALTER COLUMN "stage" SET DATA TYPE "public"."deal_stage" USING "stage"::"public"."deal_stage";--> statement-breakpoint -ALTER TABLE "people" ALTER COLUMN "status" SET DEFAULT 'lead'::"public"."people_status";--> statement-breakpoint -ALTER TABLE "people" ALTER COLUMN "status" SET DATA TYPE "public"."people_status" USING "status"::"public"."people_status";--> statement-breakpoint -ALTER TABLE "people" ALTER COLUMN "source" SET DEFAULT 'manual'::"public"."people_source";--> statement-breakpoint -ALTER TABLE "people" ALTER COLUMN "source" SET DATA TYPE "public"."people_source" USING "source"::"public"."people_source";--> statement-breakpoint -ALTER TABLE "workspace_invites" ALTER COLUMN "role" SET DEFAULT 'member'::"public"."workspace_invite_role";--> statement-breakpoint -ALTER TABLE "workspace_invites" ALTER COLUMN "role" SET DATA TYPE "public"."workspace_invite_role" USING "role"::"public"."workspace_invite_role";--> statement-breakpoint -ALTER TABLE "workspace_invites" ALTER COLUMN "status" SET DEFAULT 'pending'::"public"."workspace_invite_status";--> statement-breakpoint -ALTER TABLE "workspace_invites" ALTER COLUMN "status" SET DATA TYPE "public"."workspace_invite_status" USING "status"::"public"."workspace_invite_status";--> statement-breakpoint -ALTER TABLE "workspace_members" ALTER COLUMN "role" SET DEFAULT 'member'::"public"."workspace_role";--> statement-breakpoint -ALTER TABLE "workspace_members" ALTER COLUMN "role" SET DATA TYPE "public"."workspace_role" USING "role"::"public"."workspace_role"; \ No newline at end of file diff --git a/apps/api/src/db/drizzle/meta/0000_snapshot.json b/apps/api/src/db/drizzle/meta/0000_snapshot.json index 0a679fa..88801cc 100644 --- a/apps/api/src/db/drizzle/meta/0000_snapshot.json +++ b/apps/api/src/db/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "b818cb36-0f8a-4c28-9cff-63387bc0a9c2", + "id": "41874752-fba2-477a-bed6-e5acc7197bfc", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -408,6 +408,134 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.crm_custom_field_definitions": { + "name": "crm_custom_field_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "crm_custom_field_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "crm_custom_field_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_custom_field_definitions_workspace_entity_idx": { + "name": "crm_custom_field_definitions_workspace_entity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "crm_custom_field_definitions_workspace_id_idx": { + "name": "crm_custom_field_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "crm_custom_field_definitions_workspace_id_workspaces_id_fk": { + "name": "crm_custom_field_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "crm_custom_field_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "crm_custom_field_definitions_workspace_entity_label_unique": { + "name": "crm_custom_field_definitions_workspace_entity_label_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "entity_type", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.deals": { "name": "deals", "schema": "", @@ -464,7 +592,8 @@ }, "stage": { "name": "stage", - "type": "text", + "type": "deal_stage", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'new'" @@ -609,10 +738,10 @@ "onDelete": "set null", "onUpdate": "no action" }, - "deals_org_id_orgs_id_fk": { - "name": "deals_org_id_orgs_id_fk", + "deals_org_id_org_id_fk": { + "name": "deals_org_id_org_id_fk", "tableFrom": "deals", - "tableTo": "orgs", + "tableTo": "org", "columnsFrom": [ "org_id" ], @@ -642,8 +771,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.orgs": { - "name": "orgs", + "public.org": { + "name": "org", "schema": "", "columns": { "id": { @@ -712,8 +841,8 @@ } }, "indexes": { - "orgs_workspace_id_idx": { - "name": "orgs_workspace_id_idx", + "org_workspace_id_idx": { + "name": "org_workspace_id_idx", "columns": [ { "expression": "workspace_id", @@ -729,9 +858,9 @@ } }, "foreignKeys": { - "orgs_workspace_id_workspaces_id_fk": { - "name": "orgs_workspace_id_workspaces_id_fk", - "tableFrom": "orgs", + "org_workspace_id_workspaces_id_fk": { + "name": "org_workspace_id_workspaces_id_fk", + "tableFrom": "org", "tableTo": "workspaces", "columnsFrom": [ "workspace_id" @@ -745,8 +874,8 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "orgs_workspace_name_unique": { - "name": "orgs_workspace_name_unique", + "org_workspace_name_unique": { + "name": "org_workspace_name_unique", "nullsNotDistinct": false, "columns": [ "workspace_id", @@ -819,14 +948,16 @@ }, "status": { "name": "status", - "type": "text", + "type": "people_status", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'lead'" }, "source": { "name": "source", - "type": "text", + "type": "people_source", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'manual'" @@ -950,10 +1081,10 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "people_org_id_orgs_id_fk": { - "name": "people_org_id_orgs_id_fk", + "people_org_id_org_id_fk": { + "name": "people_org_id_org_id_fk", "tableFrom": "people", - "tableTo": "orgs", + "tableTo": "org", "columnsFrom": [ "org_id" ], @@ -1017,7 +1148,8 @@ }, "role": { "name": "role", - "type": "text", + "type": "workspace_invite_role", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'member'" @@ -1030,7 +1162,8 @@ }, "status": { "name": "status", - "type": "text", + "type": "workspace_invite_status", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'pending'" @@ -1206,7 +1339,8 @@ }, "role": { "name": "role", - "type": "text", + "type": "workspace_role", + "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'member'" @@ -1413,7 +1547,85 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.crm_custom_field_entity_type": { + "name": "crm_custom_field_entity_type", + "schema": "public", + "values": [ + "people", + "org" + ] + }, + "public.crm_custom_field_type": { + "name": "crm_custom_field_type", + "schema": "public", + "values": [ + "text", + "number", + "select", + "dateTime" + ] + }, + "public.deal_stage": { + "name": "deal_stage", + "schema": "public", + "values": [ + "new", + "contacted", + "demo", + "proposal", + "won", + "lost" + ] + }, + "public.people_source": { + "name": "people_source", + "schema": "public", + "values": [ + "manual", + "csv", + "api" + ] + }, + "public.people_status": { + "name": "people_status", + "schema": "public", + "values": [ + "lead", + "prospect", + "qualified", + "customer", + "churned" + ] + }, + "public.workspace_invite_role": { + "name": "workspace_invite_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.workspace_invite_status": { + "name": "workspace_invite_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "canceled" + ] + }, + "public.workspace_role": { + "name": "workspace_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, "schemas": {}, "sequences": {}, "roles": {}, diff --git a/apps/api/src/db/drizzle/meta/0002_snapshot.json b/apps/api/src/db/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 2f21970..0000000 --- a/apps/api/src/db/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1493 +0,0 @@ -{ - "id": "887f5236-b954-4c7b-909d-707c0958c21a", - "prevId": "b818cb36-0f8a-4c28-9cff-63387bc0a9c2", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "account_user_id_idx": { - "name": "account_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "account_provider_idx": { - "name": "account_provider_idx", - "columns": [ - { - "expression": "provider_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "active_organization_id": { - "name": "active_organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "session_user_id_idx": { - "name": "session_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "user_email_idx": { - "name": "user_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.deals": { - "name": "deals", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "person_id": { - "name": "person_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "org_id": { - "name": "org_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "owner_id": { - "name": "owner_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "currency": { - "name": "currency", - "type": "varchar(3)", - "primaryKey": false, - "notNull": true, - "default": "'USD'" - }, - "stage": { - "name": "stage", - "type": "deal_stage", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'new'" - }, - "close_date": { - "name": "close_date", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deals_workspace_id_idx": { - "name": "deals_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deals_person_id_idx": { - "name": "deals_person_id_idx", - "columns": [ - { - "expression": "person_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deals_org_id_idx": { - "name": "deals_org_id_idx", - "columns": [ - { - "expression": "org_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deals_owner_id_idx": { - "name": "deals_owner_id_idx", - "columns": [ - { - "expression": "owner_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deals_stage_idx": { - "name": "deals_stage_idx", - "columns": [ - { - "expression": "stage", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deals_close_date_idx": { - "name": "deals_close_date_idx", - "columns": [ - { - "expression": "close_date", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deals_workspace_id_workspaces_id_fk": { - "name": "deals_workspace_id_workspaces_id_fk", - "tableFrom": "deals", - "tableTo": "workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "deals_person_id_people_id_fk": { - "name": "deals_person_id_people_id_fk", - "tableFrom": "deals", - "tableTo": "people", - "columnsFrom": [ - "person_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "deals_org_id_orgs_id_fk": { - "name": "deals_org_id_orgs_id_fk", - "tableFrom": "deals", - "tableTo": "orgs", - "columnsFrom": [ - "org_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "deals_owner_id_user_id_fk": { - "name": "deals_owner_id_user_id_fk", - "tableFrom": "deals", - "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.orgs": { - "name": "orgs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "domain": { - "name": "domain", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "industry": { - "name": "industry", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "size": { - "name": "size", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "location": { - "name": "location", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "custom_fields": { - "name": "custom_fields", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "orgs_workspace_id_idx": { - "name": "orgs_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "orgs_workspace_id_workspaces_id_fk": { - "name": "orgs_workspace_id_workspaces_id_fk", - "tableFrom": "orgs", - "tableTo": "workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "orgs_workspace_name_unique": { - "name": "orgs_workspace_name_unique", - "nullsNotDistinct": false, - "columns": [ - "workspace_id", - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.people": { - "name": "people", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "org_id": { - "name": "org_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "owner_id": { - "name": "owner_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "phone": { - "name": "phone", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "job_title": { - "name": "job_title", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "linkedin_url": { - "name": "linkedin_url", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "people_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'lead'" - }, - "source": { - "name": "source", - "type": "people_source", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'manual'" - }, - "last_contacted_at": { - "name": "last_contacted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "custom_fields": { - "name": "custom_fields", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "people_workspace_id_idx": { - "name": "people_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "people_org_id_idx": { - "name": "people_org_id_idx", - "columns": [ - { - "expression": "org_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "people_owner_id_idx": { - "name": "people_owner_id_idx", - "columns": [ - { - "expression": "owner_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "people_email_idx": { - "name": "people_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "people_status_idx": { - "name": "people_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "people_workspace_id_workspaces_id_fk": { - "name": "people_workspace_id_workspaces_id_fk", - "tableFrom": "people", - "tableTo": "workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "people_org_id_orgs_id_fk": { - "name": "people_org_id_orgs_id_fk", - "tableFrom": "people", - "tableTo": "orgs", - "columnsFrom": [ - "org_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "people_owner_id_user_id_fk": { - "name": "people_owner_id_user_id_fk", - "tableFrom": "people", - "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "people_workspace_email_unique": { - "name": "people_workspace_email_unique", - "nullsNotDistinct": false, - "columns": [ - "workspace_id", - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workspace_invites": { - "name": "workspace_invites", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "workspace_invite_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "token": { - "name": "token", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "workspace_invite_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_by": { - "name": "created_by", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workspace_invites_workspace_id_idx": { - "name": "workspace_invites_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspace_invites_email_idx": { - "name": "workspace_invites_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspace_invites_status_idx": { - "name": "workspace_invites_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspace_invites_expires_at_idx": { - "name": "workspace_invites_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspace_invites_created_by_idx": { - "name": "workspace_invites_created_by_idx", - "columns": [ - { - "expression": "created_by", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "workspace_invites_workspace_id_workspaces_id_fk": { - "name": "workspace_invites_workspace_id_workspaces_id_fk", - "tableFrom": "workspace_invites", - "tableTo": "workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "workspace_invites_created_by_user_id_fk": { - "name": "workspace_invites_created_by_user_id_fk", - "tableFrom": "workspace_invites", - "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "workspace_invites_token_unique": { - "name": "workspace_invites_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workspace_members": { - "name": "workspace_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "workspace_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workspace_members_workspace_id_idx": { - "name": "workspace_members_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspace_members_user_id_idx": { - "name": "workspace_members_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "workspace_members_workspace_id_workspaces_id_fk": { - "name": "workspace_members_workspace_id_workspaces_id_fk", - "tableFrom": "workspace_members", - "tableTo": "workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "workspace_members_user_id_user_id_fk": { - "name": "workspace_members_user_id_user_id_fk", - "tableFrom": "workspace_members", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "workspace_members_workspace_user_unique": { - "name": "workspace_members_workspace_user_unique", - "nullsNotDistinct": false, - "columns": [ - "workspace_id", - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workspaces": { - "name": "workspaces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "owner_id": { - "name": "owner_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workspaces_owner_id_idx": { - "name": "workspaces_owner_id_idx", - "columns": [ - { - "expression": "owner_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspaces_slug_idx": { - "name": "workspaces_slug_idx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "workspaces_owner_id_user_id_fk": { - "name": "workspaces_owner_id_user_id_fk", - "tableFrom": "workspaces", - "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "workspaces_slug_unique": { - "name": "workspaces_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.deal_stage": { - "name": "deal_stage", - "schema": "public", - "values": [ - "new", - "contacted", - "demo", - "proposal", - "won", - "lost" - ] - }, - "public.people_source": { - "name": "people_source", - "schema": "public", - "values": [ - "manual", - "csv", - "api" - ] - }, - "public.people_status": { - "name": "people_status", - "schema": "public", - "values": [ - "lead", - "prospect", - "qualified", - "customer", - "churned" - ] - }, - "public.workspace_invite_role": { - "name": "workspace_invite_role", - "schema": "public", - "values": [ - "admin", - "member" - ] - }, - "public.workspace_invite_status": { - "name": "workspace_invite_status", - "schema": "public", - "values": [ - "pending", - "accepted", - "rejected", - "canceled" - ] - }, - "public.workspace_role": { - "name": "workspace_role", - "schema": "public", - "values": [ - "owner", - "admin", - "member" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/apps/api/src/db/drizzle/meta/_journal.json b/apps/api/src/db/drizzle/meta/_journal.json index 285658a..f15e0e5 100644 --- a/apps/api/src/db/drizzle/meta/_journal.json +++ b/apps/api/src/db/drizzle/meta/_journal.json @@ -5,22 +5,8 @@ { "idx": 0, "version": "7", - "when": 1772681340838, - "tag": "0000_unknown_red_shift", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1773000000000, - "tag": "0001_workspace_role_validation", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1775817769397, - "tag": "0002_smart_stepford_cuckoos", + "when": 1776750863845, + "tag": "0000_yellow_tomas", "breakpoints": true } ] diff --git a/apps/api/src/db/schema/crm.schema.ts b/apps/api/src/db/schema/crm.schema.ts index e2f92e3..f43e031 100644 --- a/apps/api/src/db/schema/crm.schema.ts +++ b/apps/api/src/db/schema/crm.schema.ts @@ -3,10 +3,48 @@ import { jsonb, pgTable, text, timestamp, uuid, unique, varchar, index } from "d import { id, timestamps } from "./common.schema.js"; import { user } from "./auth.schema.js"; import { workspaces } from "./workspace.schema.js"; -import { peopleStatusEnum, peopleSourceEnum, dealStageEnum } from "./enums.schema.js"; +import { + crmCustomFieldEntityTypeEnum, + crmCustomFieldTypeEnum, + peopleStatusEnum, + peopleSourceEnum, + dealStageEnum, +} from "./enums.schema.js"; -export const orgs = pgTable( - "orgs", +type CrmCustomFieldOption = { + id: string; + label: string; +}; + +export const crmCustomFieldDefinitions = pgTable( + "crm_custom_field_definitions", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + entityType: crmCustomFieldEntityTypeEnum("entity_type").notNull(), + fieldType: crmCustomFieldTypeEnum("field_type").notNull(), + label: varchar("label", { length: 255 }).notNull(), + options: jsonb("options").$type().default([]).notNull(), + ...timestamps, + }, + (table) => [ + unique("crm_custom_field_definitions_workspace_entity_label_unique").on( + table.workspaceId, + table.entityType, + table.label, + ), + index("crm_custom_field_definitions_workspace_entity_idx").on( + table.workspaceId, + table.entityType, + ), + index("crm_custom_field_definitions_workspace_id_idx").on(table.workspaceId), + ], +); + +export const org = pgTable( + "org", { ...id, workspaceId: uuid("workspace_id") @@ -21,8 +59,8 @@ export const orgs = pgTable( ...timestamps, }, (table) => [ - unique("orgs_workspace_name_unique").on(table.workspaceId, table.name), - index("orgs_workspace_id_idx").on(table.workspaceId), + unique("org_workspace_name_unique").on(table.workspaceId, table.name), + index("org_workspace_id_idx").on(table.workspaceId), ], ); @@ -33,7 +71,7 @@ export const people = pgTable( workspaceId: uuid("workspace_id") .notNull() .references(() => workspaces.id, { onDelete: "cascade" }), - orgId: uuid("org_id").references(() => orgs.id, { onDelete: "set null" }), + orgId: uuid("org_id").references(() => org.id, { onDelete: "set null" }), ownerId: uuid("owner_id").references(() => user.id, { onDelete: "set null" }), name: varchar("name", { length: 255 }).notNull(), email: varchar("email", { length: 255 }), @@ -64,7 +102,7 @@ export const deals = pgTable( .notNull() .references(() => workspaces.id, { onDelete: "cascade" }), personId: uuid("person_id").references(() => people.id, { onDelete: "set null" }), - orgId: uuid("org_id").references(() => orgs.id, { onDelete: "set null" }), + orgId: uuid("org_id").references(() => org.id, { onDelete: "set null" }), ownerId: uuid("owner_id").references(() => user.id, { onDelete: "set null" }), title: varchar("title", { length: 255 }).notNull(), value: text("value"), @@ -83,8 +121,8 @@ export const deals = pgTable( ], ); -export const orgsRelations = relations(orgs, ({ one, many }) => ({ - workspace: one(workspaces, { fields: [orgs.workspaceId], references: [workspaces.id] }), +export const orgRelations = relations(org, ({ one, many }) => ({ + workspace: one(workspaces, { fields: [org.workspaceId], references: [workspaces.id] }), people: many(people), deals: many(deals), })); @@ -94,7 +132,7 @@ export const peopleRelations = relations(people, ({ one, many }) => ({ fields: [people.workspaceId], references: [workspaces.id], }), - org: one(orgs, { fields: [people.orgId], references: [orgs.id] }), + org: one(org, { fields: [people.orgId], references: [org.id] }), owner: one(user, { fields: [people.ownerId], references: [user.id] }), deals: many(deals), })); @@ -105,6 +143,16 @@ export const dealsRelations = relations(deals, ({ one }) => ({ references: [workspaces.id], }), person: one(people, { fields: [deals.personId], references: [people.id] }), - org: one(orgs, { fields: [deals.orgId], references: [orgs.id] }), + org: one(org, { fields: [deals.orgId], references: [org.id] }), owner: one(user, { fields: [deals.ownerId], references: [user.id] }), })); + +export const crmCustomFieldDefinitionsRelations = relations( + crmCustomFieldDefinitions, + ({ one }) => ({ + workspace: one(workspaces, { + fields: [crmCustomFieldDefinitions.workspaceId], + references: [workspaces.id], + }), + }), +); diff --git a/apps/api/src/db/schema/enums.schema.ts b/apps/api/src/db/schema/enums.schema.ts index 9d9a586..ad94825 100644 --- a/apps/api/src/db/schema/enums.schema.ts +++ b/apps/api/src/db/schema/enums.schema.ts @@ -29,3 +29,15 @@ export const dealStageEnum = pgEnum("deal_stage", [ "won", "lost", ]); + +export const crmCustomFieldEntityTypeEnum = pgEnum("crm_custom_field_entity_type", [ + "people", + "org", +]); + +export const crmCustomFieldTypeEnum = pgEnum("crm_custom_field_type", [ + "text", + "number", + "select", + "dateTime", +]); diff --git a/apps/api/src/middlewares/custom-fields-auth.ts b/apps/api/src/middlewares/custom-fields-auth.ts new file mode 100644 index 0000000..bbbb00d --- /dev/null +++ b/apps/api/src/middlewares/custom-fields-auth.ts @@ -0,0 +1,9 @@ +import type { Context, Next } from "hono"; +import { assertCanManageCustomFields } from "@/utils/crm-custom-fields.js"; +import { getSessionWorkspaceId } from "@/lib/workspace.js"; + +export async function customFieldsAuthMiddleware(c: Context, next: Next) { + const workspaceId = getSessionWorkspaceId(c); + await assertCanManageCustomFields(c, workspaceId); + return next(); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 84c0323..afd3f5d 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -2,13 +2,13 @@ import type { Hono } from "hono"; import { authRoutes } from "./auth.route.js"; import { dealRoutes } from "./deals.route.js"; import { healthRoutes } from "./health.route.js"; -import { orgRoutes } from "./orgs.route.js"; +import { orgRoutes } from "./org.route.js"; import { peopleRoutes } from "./people.route.js"; export function registerRoutes(app: Hono) { app.route("/api/auth", authRoutes); app.route("/health", healthRoutes); - app.route("/orgs", orgRoutes); + app.route("/org", orgRoutes); app.route("/people", peopleRoutes); app.route("/deals", dealRoutes); } diff --git a/apps/api/src/routes/orgs.route.ts b/apps/api/src/routes/org.route.ts similarity index 53% rename from apps/api/src/routes/orgs.route.ts rename to apps/api/src/routes/org.route.ts index bf46c98..3ca04ec 100644 --- a/apps/api/src/routes/orgs.route.ts +++ b/apps/api/src/routes/org.route.ts @@ -1,9 +1,12 @@ import { Hono } from "hono"; import { bulkDeleteSchema, + createCustomFieldDefinitionSchema, + customFieldParamsSchema, createOrgSchema, listOrgsQuerySchema, orgParamsSchema, + updateCustomFieldDefinitionSchema, updateOrgSchema, } from "@workspace/validators/schemas/crm"; import { @@ -13,9 +16,16 @@ import { getOrg, listOrgs, updateOrg, -} from "@/controllers/orgs.controller.js"; +} from "@/controllers/org.controller.js"; +import { + createCustomFieldDefinition, + deleteCustomFieldDefinition, + listCustomFieldDefinitions, + updateCustomFieldDefinition, +} from "@/controllers/crm-custom-fields.controller.js"; import { VALIDATION_TARGET } from "@/constants/validation-targets.js"; import { authMiddleware } from "@/middlewares/auth-middleware.js"; +import { customFieldsAuthMiddleware } from "@/middlewares/custom-fields-auth.js"; import { validateRequest } from "@/middlewares/validate-request.js"; export const orgRoutes = new Hono() @@ -23,6 +33,32 @@ export const orgRoutes = new Hono() .get("/", validateRequest(VALIDATION_TARGET.QUERY, listOrgsQuerySchema), (c) => listOrgs(c, c.req.valid(VALIDATION_TARGET.QUERY)), ) + .get("/custom-fields", customFieldsAuthMiddleware, (c) => listCustomFieldDefinitions(c, "org")) + .post( + "/custom-fields", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.JSON, createCustomFieldDefinitionSchema), + (c) => createCustomFieldDefinition(c, "org", c.req.valid(VALIDATION_TARGET.JSON)), + ) + .patch( + "/custom-fields/:id", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.PARAM, customFieldParamsSchema), + validateRequest(VALIDATION_TARGET.JSON, updateCustomFieldDefinitionSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return updateCustomFieldDefinition(c, "org", id, c.req.valid(VALIDATION_TARGET.JSON)); + }, + ) + .delete( + "/custom-fields/:id", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.PARAM, customFieldParamsSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return deleteCustomFieldDefinition(c, "org", id); + }, + ) .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, orgParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return getOrg(c, id); diff --git a/apps/api/src/routes/people.route.ts b/apps/api/src/routes/people.route.ts index 67011b3..3b39ac6 100644 --- a/apps/api/src/routes/people.route.ts +++ b/apps/api/src/routes/people.route.ts @@ -1,9 +1,12 @@ import { Hono } from "hono"; import { bulkDeleteSchema, + createCustomFieldDefinitionSchema, + customFieldParamsSchema, createPersonSchema, listPeopleQuerySchema, personParamsSchema, + updateCustomFieldDefinitionSchema, updatePersonSchema, } from "@workspace/validators/schemas/crm"; import { @@ -14,8 +17,15 @@ import { listPeople, updatePerson, } from "@/controllers/people.controller.js"; +import { + createCustomFieldDefinition, + deleteCustomFieldDefinition, + listCustomFieldDefinitions, + updateCustomFieldDefinition, +} from "@/controllers/crm-custom-fields.controller.js"; import { VALIDATION_TARGET } from "@/constants/validation-targets.js"; import { authMiddleware } from "@/middlewares/auth-middleware.js"; +import { customFieldsAuthMiddleware } from "@/middlewares/custom-fields-auth.js"; import { validateRequest } from "@/middlewares/validate-request.js"; export const peopleRoutes = new Hono() @@ -23,6 +33,32 @@ export const peopleRoutes = new Hono() .get("/", validateRequest(VALIDATION_TARGET.QUERY, listPeopleQuerySchema), (c) => listPeople(c, c.req.valid(VALIDATION_TARGET.QUERY)), ) + .get("/custom-fields", customFieldsAuthMiddleware, (c) => listCustomFieldDefinitions(c, "people")) + .post( + "/custom-fields", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.JSON, createCustomFieldDefinitionSchema), + (c) => createCustomFieldDefinition(c, "people", c.req.valid(VALIDATION_TARGET.JSON)), + ) + .patch( + "/custom-fields/:id", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.PARAM, customFieldParamsSchema), + validateRequest(VALIDATION_TARGET.JSON, updateCustomFieldDefinitionSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return updateCustomFieldDefinition(c, "people", id, c.req.valid(VALIDATION_TARGET.JSON)); + }, + ) + .delete( + "/custom-fields/:id", + customFieldsAuthMiddleware, + validateRequest(VALIDATION_TARGET.PARAM, customFieldParamsSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return deleteCustomFieldDefinition(c, "people", id); + }, + ) .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, personParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return getPerson(c, id); diff --git a/apps/api/src/utils/crm-custom-fields.ts b/apps/api/src/utils/crm-custom-fields.ts new file mode 100644 index 0000000..e23c37f --- /dev/null +++ b/apps/api/src/utils/crm-custom-fields.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import { and, eq, ne, sql } from "drizzle-orm"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import { db } from "@/db/client.js"; +import { + crmCustomFieldDefinitions, + org, + people, + workspaceMembers, + workspaces, +} from "@/db/schema/index.js"; +import { AppError } from "@/lib/app-error.js"; +import type { CustomFieldEntityType } from "@workspace/validators/schemas/crm"; +import { User } from "better-auth"; + +type CustomFieldOption = { + id: string; + label: string; +}; + +type SelectOptionsUpdateResult = { + options: CustomFieldOption[]; + removedOptionIds: string[]; +}; + +type DbTransaction = Parameters[0]>[0]; + +export function normalizeOptions( + options: Array<{ label: string }> | undefined, +): CustomFieldOption[] { + if (!options || options.length === 0) { + return []; + } + + const normalized = options.map((option) => ({ + id: randomUUID(), + label: option.label.trim(), + })); + + const normalizedLabels = normalized.map((option) => option.label.toLowerCase()); + if (new Set(normalizedLabels).size !== normalizedLabels.length) { + throw new AppError( + "Custom field options must have unique labels", + STATUS_CODES.UNPROCESSABLE_ENTITY, + ); + } + + return normalized; +} + +export function buildUpdatedSelectOptions( + existingOptions: CustomFieldOption[], + incomingOptions: Array<{ label: string }>, +): SelectOptionsUpdateResult { + const nextLabels = incomingOptions.map((option) => option.label.trim().toLowerCase()); + + // set only keeps unique values + if (new Set(nextLabels).size !== nextLabels.length) { + throw new AppError( + "Custom field options must have unique labels", + STATUS_CODES.UNPROCESSABLE_ENTITY, + ); + } + + // index based approach + // If user removes middle item: + // - example old: [A(id1), B(id2), C(id3)] + // - new labels: [A, C] + // - index mapping gives [A(id1), C(id2)] + // - slice(2) removes id3 + // - so C gets wrong id, and wrong option id is considered removed. + const nextOptions = nextLabels.map((label, index) => ({ + id: existingOptions[index]?.id ?? randomUUID(), + label, + })); + + const removedOptionIds = existingOptions.slice(nextOptions.length).map((option) => option.id); + + return { + options: nextOptions, + removedOptionIds, + }; +} + +export async function assertLabelUnique( + workspaceId: string, + entityType: CustomFieldEntityType, + label: string, + excludedId?: string, +) { + const existing = await db.query.crmCustomFieldDefinitions.findFirst({ + where: + excludedId !== undefined + ? and( + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + eq(crmCustomFieldDefinitions.label, label), + ne(crmCustomFieldDefinitions.id, excludedId), // exclude the current record when checking for uniqueness during updates + ) + : and( + eq(crmCustomFieldDefinitions.workspaceId, workspaceId), + eq(crmCustomFieldDefinitions.entityType, entityType), + eq(crmCustomFieldDefinitions.label, label), + ), + }); + + if (existing) { + throw new AppError("A custom field with this label already exists", STATUS_CODES.CONFLICT); + } +} + +// for middleware +export async function assertCanManageCustomFields(c: Context, workspaceId: string) { + const user = c.get("user") as User | undefined; + const userId = user?.id; + if (!userId) { + throw new AppError("Unauthorized", STATUS_CODES.UNAUTHORIZED); + } + + const workspace = await db.query.workspaces.findFirst({ + where: eq(workspaces.id, workspaceId), + columns: { ownerId: true }, + }); + + if (!workspace) { + throw new AppError("Workspace not found", STATUS_CODES.NOT_FOUND); + } + + if (workspace.ownerId === userId) { + return; + } + + const membership = await db.query.workspaceMembers.findFirst({ + where: and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId)), + columns: { role: true }, + }); + + if (membership?.role !== "admin") { + throw new AppError("Forbidden", STATUS_CODES.FORBIDDEN); + } +} + +export async function clearFieldValues( + entityType: CustomFieldEntityType, + workspaceId: string, + fieldId: string, + tx?: DbTransaction, +) { + const client = tx ?? db; + if (entityType === "people") { + await client + .update(people) + .set({ + // coalese returns the first non-null value, so if customFields is null it will use an empty object casted as jsonb + // - operator removes the key from the jsonb object, effectively clearing the custom field value + customFields: sql`coalesce(${people.customFields}, '{}'::jsonb) - ${fieldId}`, + updatedAt: new Date(), + }) + .where(and(eq(people.workspaceId, workspaceId), sql`${people.customFields} ? ${fieldId}`)); // boolean check to only update records where the custom field key exists in the jsonb column + return; + } + + await client + .update(org) + .set({ + customFields: sql`coalesce(${org.customFields}, '{}'::jsonb) - ${fieldId}`, + updatedAt: new Date(), + }) + .where(and(eq(org.workspaceId, workspaceId), sql`${org.customFields} ? ${fieldId}`)); +} + +export async function clearOptionValue( + entityType: CustomFieldEntityType, + workspaceId: string, + fieldId: string, + optionId: string, + tx?: DbTransaction, +) { + const client = tx ?? db; + if (entityType === "people") { + await client + .update(people) + .set({ + customFields: sql`coalesce(${people.customFields}, '{}'::jsonb) - ${fieldId}`, + updatedAt: new Date(), + }) + // for select cfid and option id is stored + .where( + and( + eq(people.workspaceId, workspaceId), + sql`${people.customFields} ->> ${fieldId} = ${optionId}`, // --> access value using key + ), + ); + return; + } + + await client + .update(org) + .set({ + customFields: sql`coalesce(${org.customFields}, '{}'::jsonb) - ${fieldId}`, + updatedAt: new Date(), + }) + .where( + and( + eq(org.workspaceId, workspaceId), + sql`${org.customFields} ->> ${fieldId} = ${optionId}`, + ), + ); +} diff --git a/apps/web/app/(crm)/deals/page.tsx b/apps/web/app/(crm)/deals/page.tsx index 20ae2be..e9feb59 100644 --- a/apps/web/app/(crm)/deals/page.tsx +++ b/apps/web/app/(crm)/deals/page.tsx @@ -1,78 +1,79 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { Plus } from "lucide-react"; import { Button } from "@workspace/ui/components/ui/button"; import { PageHeader } from "@/components/layout/page-header"; -import { DealsKanban, type DrawerState } from "@/components/crm/deals/deals-kanban"; +import { DealsKanban } from "@/components/crm/deals/deals-kanban"; import { DealsDrawer } from "@/components/crm/deals/deals-drawer"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { useDeleteDeal } from "@/hooks/queries/use-deals"; +import { useEntityDrawer } from "@/hooks/use-entity-drawer"; +import { useEntityDelete } from "@/hooks/use-entity-delete"; import type { EntitySheetMode } from "@/components/shared/entity-sheet"; import type { Deal } from "@/types/crm"; export default function DealsPage() { - const [drawer, setDrawer] = useState({ - open: false, - mode: "view", - }); - const [deleteTarget, setDeleteTarget] = useState(null); + const drawer = useEntityDrawer(); + const deleteDialog = useEntityDelete(); + const [initialStage, setInitialStage] = useState(undefined); const { mutate: deleteDeal, isPending: isDeleting } = useDeleteDeal(); - function openDrawer(mode: EntitySheetMode, deal?: Deal, initialStage?: string) { - setDrawer({ open: true, mode, deal, initialStage }); - } - - function closeDrawer() { - setDrawer((prev) => ({ ...prev, open: false })); - } + const handleOpenDrawer = useCallback( + (mode: EntitySheetMode, deal?: Deal, stage?: string) => { + drawer.openDrawer(mode, deal); + setInitialStage(stage); + }, + [drawer], + ); function handleDeleteConfirm() { - if (!deleteTarget) return; - deleteDeal(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), + if (!deleteDialog.deleteTarget || deleteDialog.deleteTarget === "bulk") return; + deleteDeal(deleteDialog.deleteTarget.id, { + onSuccess: () => deleteDialog.closeDelete(), }); } + const confirmTitle = + deleteDialog.deleteTarget && deleteDialog.deleteTarget !== "bulk" + ? `Delete "${deleteDialog.deleteTarget.title}"?` + : ""; + return (
openDrawer("create")}> + } /> - + { - if (!open) closeDrawer(); + drawer.onDrawerOpenChange(open); + if (!open) setInitialStage(undefined); }} - mode={drawer.mode} - onModeChange={(mode) => - setDrawer((prev) => ({ ...prev, mode })) - } - deal={drawer.deal} - initialStage={drawer.initialStage} - onDeleteSuccess={() => setDeleteTarget(null)} + mode={drawer.drawer.mode} + onModeChange={drawer.onDrawerModeChange} + deal={drawer.drawer.entity} + initialStage={initialStage} + onDeleteSuccess={deleteDialog.closeDelete} /> { - if (!open) setDeleteTarget(null); + if (!open) deleteDialog.closeDelete(); }} - title={`Delete "${deleteTarget?.title}"?`} + title={confirmTitle} description="This will permanently remove this deal from your pipeline. This action cannot be undone." confirmLabel={isDeleting ? "Deleting…" : "Delete"} variant="destructive" diff --git a/apps/web/app/(crm)/organizations/page.tsx b/apps/web/app/(crm)/organizations/page.tsx index 4e31d64..c77e4fe 100644 --- a/apps/web/app/(crm)/organizations/page.tsx +++ b/apps/web/app/(crm)/organizations/page.tsx @@ -1,4 +1,4 @@ -import { OrgsDataTable } from "@/components/crm/orgs/orgs-data-table"; +import { OrgsDataTable } from "@/components/crm/org/org-data-table"; export default function OrganizationsPage() { return ; diff --git a/apps/web/app/(crm)/settings/custom-fields/organizations/page.tsx b/apps/web/app/(crm)/settings/custom-fields/organizations/page.tsx new file mode 100644 index 0000000..9d9641d --- /dev/null +++ b/apps/web/app/(crm)/settings/custom-fields/organizations/page.tsx @@ -0,0 +1,5 @@ +import { OrgCustomFieldsManager } from "@/components/workspace/settings/custom-fields-manager"; + +export default function OrganizationsCustomFieldsPage() { + return ; +} diff --git a/apps/web/app/(crm)/settings/custom-fields/page.tsx b/apps/web/app/(crm)/settings/custom-fields/page.tsx new file mode 100644 index 0000000..42972e8 --- /dev/null +++ b/apps/web/app/(crm)/settings/custom-fields/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { Users, Building2, ArrowRight } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/ui/card"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { usePeopleCustomFields, useOrgCustomFields } from "@/hooks/queries/use-crm-custom-fields"; + +export default function CustomFieldsLandingPage() { + const peopleQuery = usePeopleCustomFields(); + const orgQuery = useOrgCustomFields(); + + const peopleCount = peopleQuery.data?.length ?? 0; + const orgCount = orgQuery.data?.length ?? 0; + + return ( +
+
+

Custom fields

+

+ Manage workspace-wide custom fields for people and organizations. +

+
+ +
+ + + +
+
+ +
+
+ People + Manage people custom fields +
+
+ +
+ +
+ {peopleCount} field{peopleCount === 1 ? "" : "s"} + {peopleQuery.isLoading && ( + Loading… + )} +
+
+
+ + + + + +
+
+ +
+
+ Organizations + Manage organization custom fields +
+
+ +
+ +
+ {orgCount} field{orgCount === 1 ? "" : "s"} + {orgQuery.isLoading && ( + Loading… + )} +
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/(crm)/settings/custom-fields/people/page.tsx b/apps/web/app/(crm)/settings/custom-fields/people/page.tsx new file mode 100644 index 0000000..7f23dd4 --- /dev/null +++ b/apps/web/app/(crm)/settings/custom-fields/people/page.tsx @@ -0,0 +1,5 @@ +import { PeopleCustomFieldsManager } from "@/components/workspace/settings/custom-fields-manager"; + +export default function PeopleCustomFieldsPage() { + return ; +} diff --git a/apps/web/app/(crm)/settings/general/page.tsx b/apps/web/app/(crm)/settings/general/page.tsx new file mode 100644 index 0000000..be61aad --- /dev/null +++ b/apps/web/app/(crm)/settings/general/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { LoadingState } from "@/components/shared/loading-state"; +import { ErrorState } from "@/components/shared/error-state"; +import { GeneralSettings } from "@/components/workspace/settings/general-settings"; +import { useActiveWorkspace } from "@/hooks/queries/use-workspace"; + +export default function GeneralSettingsPage() { + const { data: workspace, isPending, isError, refetch } = useActiveWorkspace(); + + if (isPending) { + return ; + } + + if (isError || !workspace) { + return ( + refetch()} + /> + ); + } + + return ( + ).ownerId as string | undefined, + }} + /> + ); +} diff --git a/apps/web/app/(crm)/settings/invitations/page.tsx b/apps/web/app/(crm)/settings/invitations/page.tsx new file mode 100644 index 0000000..5870add --- /dev/null +++ b/apps/web/app/(crm)/settings/invitations/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { LoadingState } from "@/components/shared/loading-state"; +import { ErrorState } from "@/components/shared/error-state"; +import { InvitationsSettings } from "@/components/workspace/settings/invitations-settings"; +import { useWorkspaceInvitations } from "@/hooks/queries/use-workspace"; + +export default function InvitationsSettingsPage() { + const { + data: invitations, + isPending, + isError, + refetch, + } = useWorkspaceInvitations(); + + if (isPending) { + return ; + } + + if (isError || !invitations) { + return ( + refetch()} + /> + ); + } + + return ; +} diff --git a/apps/web/app/(crm)/settings/layout.tsx b/apps/web/app/(crm)/settings/layout.tsx new file mode 100644 index 0000000..e6ee027 --- /dev/null +++ b/apps/web/app/(crm)/settings/layout.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@workspace/ui/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/ui/tooltip"; +import { Button } from "@workspace/ui/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { SETTINGS_NAV_ITEMS } from "@/constants/navigation"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const segments = pathname.split("/"); + const lastSegment = segments.pop() ?? "general"; + const parentSegment = segments.pop(); + const activeId = + parentSegment === "custom-fields" ? "custom-fields" : lastSegment; + const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.id === activeId); + const title = activeItem?.label ?? "Settings"; + const isCustomFieldsSubRoute = parentSegment === "custom-fields"; + + return ( +
+
+ {SETTINGS_NAV_ITEMS.map(({ id, label, icon: Icon }) => { + const isActive = activeId === id; + return ( + + + + + {isActive && ( + + )} + {label} + + + + {label} + + + ); + })} +
+ +
+
+ {isCustomFieldsSubRoute && ( + + )} +

{title}

+
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/app/(crm)/settings/members/page.tsx b/apps/web/app/(crm)/settings/members/page.tsx new file mode 100644 index 0000000..5df812e --- /dev/null +++ b/apps/web/app/(crm)/settings/members/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { LoadingState } from "@/components/shared/loading-state"; +import { ErrorState } from "@/components/shared/error-state"; +import { MembersSettings } from "@/components/workspace/settings/members-settings"; +import { useActiveWorkspace, useWorkspaceMembers } from "@/hooks/queries/use-workspace"; + +export default function MembersSettingsPage() { + const { + data: members, + isPending: isMembersPending, + isError: isMembersError, + refetch: refetchMembers, + } = useWorkspaceMembers(); + + const { + data: workspace, + isPending: isWorkspacePending, + isError: isWorkspaceError, + refetch: refetchWorkspace, + } = useActiveWorkspace(); + + const isPending = isMembersPending || isWorkspacePending; + const isError = isMembersError || isWorkspaceError; + + if (isPending) { + return ; + } + + if (isError || !members || !workspace) { + return ( + { + refetchMembers(); + refetchWorkspace(); + }} + /> + ); + } + + return ( + ).ownerId as string | undefined} + /> + ); +} diff --git a/apps/web/app/(crm)/settings/page.tsx b/apps/web/app/(crm)/settings/page.tsx index eadf978..13bc5f6 100644 --- a/apps/web/app/(crm)/settings/page.tsx +++ b/apps/web/app/(crm)/settings/page.tsx @@ -1,79 +1,5 @@ -"use client"; - -import { useState } from "react"; -import { LoadingState } from "@/components/shared/loading-state"; -import { ErrorState } from "@/components/shared/error-state"; -import { GeneralSettings } from "@/components/workspace/settings/general-settings"; -import { MembersSettings } from "@/components/workspace/settings/members-settings"; -import { InvitationsSettings } from "@/components/workspace/settings/invitations-settings"; -import { SettingsSidebar } from "@/components/workspace/settings/settings-sidebar"; -import { SETTINGS_NAV_ITEMS, type SettingsTab } from "@/constants/navigation"; -import { useAuthSession } from "@/hooks/queries/use-auth"; -import { useActiveWorkspace } from "@/hooks/queries/use-workspace"; -import { WORKSPACE_ROLE } from "@workspace/validators/schemas/common"; -import type { WorkspaceRole } from "@workspace/validators/types/workspace"; +import { redirect } from "next/navigation"; export default function SettingsPage() { - const { data: session } = useAuthSession(); - const { data: workspace, isPending, isError, refetch } = useActiveWorkspace(); - const [activeTab, setActiveTab] = useState("general"); - - if (isPending) { - return ; - } - - if (isError || !workspace) { - return ( - refetch()} - /> - ); - } - - const currentUserId = session?.user?.id; - const currentMember = workspace.members?.find( - (member: { userId: string }) => member.userId === currentUserId, - ); - const currentUserRole = - (currentMember?.role as WorkspaceRole | undefined) ?? WORKSPACE_ROLE.member; - const pendingInvitations = workspace.invitations?.filter((inv) => inv.status === "pending") ?? []; - - const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.id === activeTab)!; - - return ( -
- - -
-
-

{activeItem.label}

-
- -
- {activeTab === "general" && } - {activeTab === "members" && ( - ).ownerId as string | undefined} - /> - )} - {activeTab === "invitations" && ( - - )} -
-
-
- ); + redirect("/settings/general"); } diff --git a/apps/web/components/crm/deals/deals-drawer.tsx b/apps/web/components/crm/deals/deals-drawer.tsx index 2e2519b..c1123e2 100644 --- a/apps/web/components/crm/deals/deals-drawer.tsx +++ b/apps/web/components/crm/deals/deals-drawer.tsx @@ -28,7 +28,7 @@ import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sh import { CrmViewField, CrmViewSection } from "@/components/crm/crm-view"; import { useCreateDeal, useUpdateDeal, useDeleteDeal } from "@/hooks/queries/use-deals"; import { usePeople } from "@/hooks/queries/use-people"; -import { useOrganizations } from "@/hooks/queries/use-orgs"; +import { useOrganizations } from "@/hooks/queries/use-org"; import { useActiveWorkspace } from "@/hooks/queries/use-workspace"; import type { Deal } from "@/types/crm"; import type { WorkspaceMember } from "@/types/workspace-settings"; @@ -115,11 +115,11 @@ function DealForm({ isPending: boolean; }) { const { data: peopleData } = usePeople({ pageSize: 100 }); - const { data: orgsData } = useOrganizations({ pageSize: 100 }); + const { data: orgData } = useOrganizations({ pageSize: 100 }); const { data: workspace } = useActiveWorkspace(); const people = peopleData?.people ?? []; - const orgs = orgsData?.orgs ?? []; + const org = orgData?.org ?? []; const members = (workspace?.members ?? []) as Pick[]; return ( @@ -293,9 +293,9 @@ function DealForm({ No organization - {orgs.map((org) => ( - - {org.name} + {org.map((o) => ( + + {o.name} ))} diff --git a/apps/web/components/crm/deals/deals-kanban.tsx b/apps/web/components/crm/deals/deals-kanban.tsx index d53a125..2e453c7 100644 --- a/apps/web/components/crm/deals/deals-kanban.tsx +++ b/apps/web/components/crm/deals/deals-kanban.tsx @@ -20,28 +20,16 @@ import type { Deal } from "@/types/crm"; import type { DealStage } from "@workspace/validators/schemas/crm"; import { DEAL_STAGE_OPTIONS } from "./deals-options"; import type { EntitySheetMode } from "@/components/shared/entity-sheet"; +import { ErrorState } from "@/components/shared/error-state"; +import { LoadingState } from "@/components/shared/loading-state"; // ─── Types ──────────────────────────────────────────────────────────────────── -export interface DrawerState { - open: boolean; - mode: EntitySheetMode; - deal?: Deal; - initialStage?: string; -} - interface DealsKanbanProps { - drawerState: DrawerState; - onDrawerStateChange: (state: DrawerState) => void; + onOpenDrawer: (mode: EntitySheetMode, deal?: Deal, initialStage?: string) => void; } -// ─── Deal Card ──────────────────────────────────────────────────────────────── - function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) { - const personName = deal.person?.name; - const orgName = deal.org?.name; - const ownerName = deal.owner?.name; - return (
void }) { {/* Meta */}
- {personName && ( + {deal.personName && (
- {personName} + {deal.personName}
)} - {orgName && ( + {deal.orgName && (
- {orgName} + {deal.orgName}
)} {deal.closeDate && ( @@ -93,17 +81,15 @@ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) { )}
- {ownerName && ( + {deal.ownerName && (
- {ownerName} + {deal.ownerName}
)}
); } -// ─── Overlay ghost card ─────────────────────────────────────────────────────── - function DealCardGhost({ deal }: { deal: Deal }) { return (
@@ -120,9 +106,7 @@ function DealCardGhost({ deal }: { deal: Deal }) { ); } -// ─── Kanban board ───────────────────────────────────────────────────────────── - -export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { +export function DealsKanban({ onOpenDrawer }: DealsKanbanProps) { const { data, isLoading, isError } = useDeals({ pageSize: 100 }); const { mutate: updateDeal } = useUpdateDeal(); @@ -169,50 +153,11 @@ export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { updateDeal({ dealId: movedDeal.id, input: { stage: newStage } }); } - function openDrawer(mode: EntitySheetMode, deal?: Deal, initialStage?: string) { - onDrawerStateChange({ open: true, mode, deal, initialStage }); - } - - // ─── Loading ──────────────────────────────────────────────────────────────── - if (isLoading) { - return ( -
- {DEAL_STAGE_OPTIONS.map((stage) => ( -
-
-
-
-
- {Array.from({ length: 2 }).map((_, i) => ( -
-
-
-
- ))} -
-
- ))} -
- ); - } - - // ─── Error ────────────────────────────────────────────────────────────────── - - if (isError) { - return ( -
- Failed to load deals. Please refresh the page. -
- ); + return ; } - // ─── Render ───────────────────────────────────────────────────────────────── + if (isError) return ; return ( ( - openDrawer("view", deal)} /> + onOpenDrawer("view", deal)} /> ))} @@ -280,7 +225,7 @@ export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { {/* Add deal */} + ) : null; + + const confirmTitle = deleteDialog.isBulkDelete + ? `Delete ${selectedCount} organization${selectedCount === 1 ? "" : "s"}?` + : `Delete "${(deleteDialog.deleteTarget as Organization | null)?.name}"?`; + + const confirmDescription = deleteDialog.isBulkDelete + ? `This will permanently remove ${selectedCount} organization${ + selectedCount === 1 ? "" : "s" + }. People linked to ${selectedCount === 1 ? "it" : "them"} will have their organization cleared. This action cannot be undone.` + : `This will permanently delete "${(deleteDialog.deleteTarget as Organization | null)?.name}". People linked to this organization will have their organization cleared. This action cannot be undone.`; + + return ( + <> + drawer.openDrawer("create")}> + + Add Organization + + } + /> + + row.id} + onRowClick={(org) => drawer.openDrawer("view", org)} + emptyTitle="No organizations yet" + emptyDescription="Add your first organization to start tracking companies in your CRM." + toolbarActions={toolbarActions} + /> + + + + { + if (!open) deleteDialog.closeDelete(); + }} + title={confirmTitle} + description={confirmDescription} + confirmLabel="Delete" + variant="destructive" + isPending={isDeletePending} + onConfirm={handleConfirmDelete} + /> + + ); +} diff --git a/apps/web/components/crm/orgs/orgs-drawer.tsx b/apps/web/components/crm/org/org-drawer.tsx similarity index 66% rename from apps/web/components/crm/orgs/orgs-drawer.tsx rename to apps/web/components/crm/org/org-drawer.tsx index 3ec3d60..9fcf61b 100644 --- a/apps/web/components/crm/orgs/orgs-drawer.tsx +++ b/apps/web/components/crm/org/org-drawer.tsx @@ -24,9 +24,14 @@ import { import { Separator } from "@workspace/ui/components/ui/separator"; import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sheet"; import { CrmViewField, CrmViewSection } from "@/components/crm/crm-view"; -import { useCreateOrg, useUpdateOrg, useDeleteOrg } from "@/hooks/queries/use-orgs"; -import type { Organization } from "@/types/crm"; +import { useCreateOrg, useUpdateOrg, useDeleteOrg } from "@/hooks/queries/use-org"; +import type { CustomFieldDefinition, Organization } from "@/types/crm"; import { ORG_INDUSTRY_OPTIONS, ORG_SIZE_OPTIONS } from "@/components/crm/crm-options"; +import { + buildCustomFieldsPayload, + formatCustomFieldValueForView, + toDateTimeInputValue, +} from "@/lib/crm-custom-fields"; function capitalize(str: string | null | undefined): string | null | undefined { if (!str) return str; @@ -43,7 +48,13 @@ function OrgAvatar() { ); } -function ViewContent({ org }: { org: Organization }) { +function ViewContent({ + org, + customFields, +}: { + org: Organization; + customFields: CustomFieldDefinition[]; +}) { return (
{/* Identity */} @@ -59,16 +70,29 @@ function ViewContent({ org }: { org: Organization }) { - {capitalize(org.industry) ?? } + {capitalize(org.industry) ?? Not set} - {org.size ?? } + {org.size ?? Not set} - {org.location ?? } + {org.location ?? Not set} + {customFields.length > 0 && ( + <> + + + {customFields.map((field) => ( + + {formatCustomFieldValueForView(field, org.customFields?.[field.id])} + + ))} + + + )} + {org.peopleCount !== undefined && ( <> @@ -86,9 +110,11 @@ function ViewContent({ org }: { org: Organization }) { function OrgForm({ form, isPending, + customFields, }: { form: ReturnType>; isPending: boolean; + customFields: CustomFieldDefinition[]; }) { return (
@@ -212,6 +238,80 @@ function OrgForm({ )} /> + + {customFields.length > 0 && ( + <> + +
+

+ Custom fields +

+ + {customFields.map((customField) => { + const fieldName = `customFields.${customField.id}` as const; + + return ( + ( + + {customField.label} + + {customField.fieldType === "select" ? ( + + ) : customField.fieldType === "number" ? ( + field.onChange(event.target.value)} + disabled={isPending} + /> + ) : customField.fieldType === "dateTime" ? ( + field.onChange(event.target.value || undefined)} + disabled={isPending} + /> + ) : ( + field.onChange(event.target.value)} + disabled={isPending} + /> + )} + + + + )} + /> + ); + })} +
+ + )}
); @@ -225,9 +325,17 @@ interface OrgDrawerProps { mode: EntitySheetMode; onModeChange: (mode: EntitySheetMode) => void; org?: Organization; + customFields?: CustomFieldDefinition[]; } -export function OrgDrawer({ open, onOpenChange, mode, onModeChange, org }: OrgDrawerProps) { +export function OrgDrawer({ + open, + onOpenChange, + mode, + onModeChange, + org, + customFields = [], +}: OrgDrawerProps) { const { mutate: createOrgMutate, isPending: isCreating } = useCreateOrg(); const { mutate: updateOrgMutate, isPending: isUpdating } = useUpdateOrg(org?.id ?? ""); const { mutate: deleteOrgMutate, isPending: isDeleting } = useDeleteOrg(); @@ -241,6 +349,7 @@ export function OrgDrawer({ open, onOpenChange, mode, onModeChange, org }: OrgDr industry: mode === "create" ? "" : (org?.industry ?? ""), size: mode === "create" ? "" : (org?.size ?? ""), location: mode === "create" ? "" : (org?.location ?? ""), + customFields: mode === "create" ? undefined : (org?.customFields ?? undefined), }), [mode, org], ); @@ -258,6 +367,10 @@ export function OrgDrawer({ open, onOpenChange, mode, onModeChange, org }: OrgDr industry: values.industry || undefined, size: values.size || undefined, location: values.location || undefined, + customFields: buildCustomFieldsPayload( + customFields, + (values.customFields ?? {}) as Record, + ), }; if (mode === "create") { @@ -300,9 +413,9 @@ export function OrgDrawer({ open, onOpenChange, mode, onModeChange, org }: OrgDr deleteLabel={isDeleting ? "Deleting…" : "Delete"} > {mode === "view" && org ? ( - + ) : ( - + )} ); diff --git a/apps/web/components/crm/orgs/orgs-filters.tsx b/apps/web/components/crm/org/org-filters.tsx similarity index 100% rename from apps/web/components/crm/orgs/orgs-filters.tsx rename to apps/web/components/crm/org/org-filters.tsx diff --git a/apps/web/components/crm/orgs/orgs-data-table.tsx b/apps/web/components/crm/orgs/orgs-data-table.tsx deleted file mode 100644 index 2a9b19d..0000000 --- a/apps/web/components/crm/orgs/orgs-data-table.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { ColumnFiltersState, RowSelectionState, SortingState } from "@tanstack/react-table"; -import { Plus, Trash2 } from "lucide-react"; -import { Button } from "@workspace/ui/components/ui/button"; -import { PageHeader } from "@/components/layout/page-header"; -import { DataTable } from "@/components/shared/data-table"; -import { ConfirmDialog } from "@/components/shared/confirm-dialog"; -import { getOrgsColumns } from "./orgs-columns"; -import { ORGS_FILTER_CONFIG } from "./orgs-filters"; -import { OrgDrawer } from "./orgs-drawer"; -import { useBulkDeleteOrgs, useDeleteOrg, useOrganizations } from "@/hooks/queries/use-orgs"; -import { useDebounceValue } from "usehooks-ts"; -import type { Organization, OrganizationsListParams } from "@/types/crm"; -import type { EntitySheetMode } from "@/components/shared/entity-sheet"; - -type DrawerState = { - open: boolean; - mode: EntitySheetMode; - org?: Organization; -}; - -type OrgsTableState = { - pagination: { pageIndex: number; pageSize: number }; - sorting: SortingState; - columnFilters: ColumnFiltersState; - searchInput: string; - rowSelection: RowSelectionState; -}; - -type OrgsUiState = { - drawer: DrawerState; - deleteTarget: Organization | "bulk" | null; -}; - -export function OrgsDataTable() { - const [table, setTable] = useState({ - pagination: { pageIndex: 0, pageSize: 25 }, - sorting: [], - columnFilters: [], - searchInput: "", - rowSelection: {}, - }); - const [ui, setUi] = useState({ - drawer: { open: false, mode: "view" }, - deleteTarget: null, - }); - - // Debounced search value drives the query; raw input drives the input element - const [debouncedSearch] = useDebounceValue(table.searchInput, 300); - - // Derive filter values from TanStack columnFilters - const industryFilter = table.columnFilters.find((f) => f.id === "industry")?.value as - | string - | undefined; - const sizeFilter = table.columnFilters.find((f) => f.id === "size")?.value as string | undefined; - - // Build server query params - const queryParams: OrganizationsListParams = { - page: table.pagination.pageIndex + 1, - pageSize: table.pagination.pageSize, - ...(table.sorting[0] && { - sortBy: table.sorting[0].id as OrganizationsListParams["sortBy"], - sortOrder: table.sorting[0].desc ? "desc" : "asc", - }), - ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), - ...(industryFilter && { industry: industryFilter }), - ...(sizeFilter && { size: sizeFilter }), - }; - - // Data + mutations - const { data, isLoading, isError, refetch } = useOrganizations(queryParams); - const { mutate: bulkDeleteMutate, isPending: isBulkDeleting } = useBulkDeleteOrgs(); - const { mutate: deleteOrgMutate, isPending: isDeleting } = useDeleteOrg(); - - const orgs = data?.orgs ?? []; - const totalCount = data?.meta.totalCount ?? 0; - const pageCount = data?.meta.totalPages ?? 0; - - const selectedIds = Object.keys(table.rowSelection).filter((id) => table.rowSelection[id]); - const selectedCount = selectedIds.length; - const isDeletePending = isBulkDeleting || isDeleting; - - function updateTable(next: Partial) { - setTable((current) => ({ ...current, ...next })); - } - - function openDrawer(mode: EntitySheetMode, org?: Organization) { - setUi((current) => ({ - ...current, - drawer: { open: true, mode, org }, - })); - } - - const columns = getOrgsColumns({ - onView: (org) => openDrawer("view", org), - onEdit: (org) => openDrawer("edit", org), - onDelete: (org) => setUi((current) => ({ ...current, deleteTarget: org })), - }); - - // Handlers - function handleSortingChange(next: SortingState) { - setTable((current) => ({ - ...current, - sorting: next, - pagination: { ...current.pagination, pageIndex: 0 }, - })); - } - - function handleConfirmDelete() { - if (ui.deleteTarget === "bulk") { - bulkDeleteMutate( - { ids: selectedIds }, - { - onSuccess: () => { - setTable((current) => ({ ...current, rowSelection: {} })); - setUi((current) => ({ ...current, deleteTarget: null })); - }, - }, - ); - } else if (ui.deleteTarget) { - deleteOrgMutate(ui.deleteTarget.id, { - onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), - }); - } - } - - // Toolbar: only shows when rows are selected - const toolbarActions = - selectedCount > 0 ? ( - - ) : null; - - // Confirm dialog copy - const isBulkTarget = ui.deleteTarget === "bulk"; - const confirmTitle = isBulkTarget - ? `Delete ${selectedCount} organization${selectedCount === 1 ? "" : "s"}?` - : `Delete "${(ui.deleteTarget as Organization | null)?.name}"?`; - - const confirmDescription = isBulkTarget - ? `This will permanently remove ${selectedCount} organization${ - selectedCount === 1 ? "" : "s" - }. People linked to ${selectedCount === 1 ? "it" : "them"} will have their organization cleared. This action cannot be undone.` - : `This will permanently delete "${(ui.deleteTarget as Organization | null)?.name}". People linked to this organization will have their organization cleared. This action cannot be undone.`; - - return ( - <> - openDrawer("create")}> - - Add Organization - - } - /> - - updateTable({ pagination })} - sorting={table.sorting} - onSortingChange={handleSortingChange} - columnFilters={table.columnFilters} - onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} - searchValue={table.searchInput} - onSearchChange={(searchInput) => updateTable({ searchInput })} - searchPlaceholder="Search organizations…" - filterConfig={ORGS_FILTER_CONFIG} - isLoading={isLoading} - isError={isError} - errorTitle="Failed to load organizations" - errorDescription="There was a problem fetching your organizations." - onRetry={refetch} - enableRowSelection - rowSelection={table.rowSelection} - onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} - getRowId={(row) => row.id} - onRowClick={(org) => openDrawer("view", org)} - emptyTitle="No organizations yet" - emptyDescription="Add your first organization to start tracking companies in your CRM." - toolbarActions={toolbarActions} - /> - - - setUi((current) => ({ ...current, drawer: { ...current.drawer, open } })) - } - mode={ui.drawer.mode} - onModeChange={(mode) => - setUi((current) => ({ ...current, drawer: { ...current.drawer, mode } })) - } - org={ui.drawer.org} - /> - - { - if (!open) setUi((current) => ({ ...current, deleteTarget: null })); - }} - title={confirmTitle} - description={confirmDescription} - confirmLabel="Delete" - variant="destructive" - isPending={isDeletePending} - onConfirm={handleConfirmDelete} - /> - - ); -} diff --git a/apps/web/components/crm/people/people-columns.tsx b/apps/web/components/crm/people/people-columns.tsx index 82201bd..4861054 100644 --- a/apps/web/components/crm/people/people-columns.tsx +++ b/apps/web/components/crm/people/people-columns.tsx @@ -5,8 +5,9 @@ import type { ColumnDef } from "@tanstack/react-table"; import { Badge } from "@workspace/ui/components/ui/badge"; import { cn } from "@workspace/ui/lib/utils"; import { CrmRowActions } from "@/components/crm/crm-row-actions"; -import type { Person } from "@/types/crm"; +import type { CustomFieldDefinition, Person } from "@/types/crm"; import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; +import { buildCustomFieldColumns } from "@/lib/crm-custom-fields"; // ─── Column factory ────────────────────────────────────────────────────────── @@ -14,16 +15,18 @@ interface GetPeopleColumnsProps { onView: (person: Person) => void; onEdit: (person: Person) => void; onDelete: (person: Person) => void; + customFields?: CustomFieldDefinition[]; } export function getPeopleColumns({ onView, onEdit, onDelete, + customFields = [], }: GetPeopleColumnsProps): ColumnDef[] { const emptyCell = ; - return [ + const baseColumns: ColumnDef[] = [ { id: "name", accessorKey: "name", @@ -148,4 +151,9 @@ export function getPeopleColumns({ ), }, ]; + + const customColumns = buildCustomFieldColumns(customFields); + const actionColumn = baseColumns[baseColumns.length - 1]!; + + return [...baseColumns.slice(0, -1), ...customColumns, actionColumn]; } diff --git a/apps/web/components/crm/people/people-data-table.tsx b/apps/web/components/crm/people/people-data-table.tsx index afd245a..0a76bce 100644 --- a/apps/web/components/crm/people/people-data-table.tsx +++ b/apps/web/components/crm/people/people-data-table.tsx @@ -1,7 +1,5 @@ "use client"; -import { useState } from "react"; -import type { ColumnFiltersState, RowSelectionState, SortingState } from "@tanstack/react-table"; import { Plus, Trash2 } from "lucide-react"; import { Button } from "@workspace/ui/components/ui/button"; import { DataTable } from "@/components/shared/data-table"; @@ -11,128 +9,86 @@ import { getPeopleColumns } from "./people-columns"; import { PEOPLE_FILTER_CONFIG } from "./people-filters"; import { PeopleDrawer } from "./people-drawer"; import { usePeople, useDeletePerson, useBulkDeletePeople } from "@/hooks/queries/use-people"; -import { useDebounceValue } from "usehooks-ts"; -import type { EntitySheetMode } from "@/components/shared/entity-sheet"; -import type { Person, PeopleListParams } from "@/types/crm"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -type DrawerState = { - open: boolean; - mode: EntitySheetMode; - person?: Person; -}; - -// A Person = single delete, "bulk" = bulk delete -type DeleteTarget = Person | "bulk" | null; - -type PeopleTableState = { - pagination: { pageIndex: number; pageSize: number }; - sorting: SortingState; - columnFilters: ColumnFiltersState; - rowSelection: RowSelectionState; - searchInput: string; -}; - -type PeopleUiState = { - drawer: DrawerState; - deleteTarget: DeleteTarget; -}; - -// ─── Component ──────────────────────────────────────────────────────────────── +import { usePeopleCustomFields } from "@/hooks/queries/use-crm-custom-fields"; +import { useDataTableState } from "@/hooks/use-data-table-state"; +import { useEntityDrawer } from "@/hooks/use-entity-drawer"; +import { useEntityDelete } from "@/hooks/use-entity-delete"; +import type { CustomFieldDefinition, Person, PeopleListParams } from "@/types/crm"; export function PeopleDataTable() { - const [table, setTable] = useState({ - pagination: { pageIndex: 0, pageSize: 25 }, - sorting: [], - columnFilters: [], - rowSelection: {}, - searchInput: "", - }); - const [ui, setUi] = useState({ - drawer: { open: false, mode: "view" }, - deleteTarget: null, - }); - - // Debounce search to avoid a query on every keystroke - const [debouncedSearch] = useDebounceValue(table.searchInput, 350); - - // Extract individual filter values from the TanStack ColumnFiltersState - const statusFilter = table.columnFilters.find((f) => f.id === "status")?.value as - | string - | undefined; - const sourceFilter = table.columnFilters.find((f) => f.id === "source")?.value as - | string - | undefined; + const table = useDataTableState({ defaultPageSize: 25, debounceMs: 350 }); + const drawer = useEntityDrawer(); + const deleteDialog = useEntityDelete({ enableBulkDelete: true }); + + const statusFilter = table.getFilterValue("status") as string | undefined; + const sourceFilter = table.getFilterValue("source") as string | undefined; + + const { data: customFieldsData } = usePeopleCustomFields(); + const customFields = (customFieldsData ?? []) as CustomFieldDefinition[]; + const nativeSortableColumns = new Set([ + "name", + "email", + "phone", + "jobTitle", + "status", + "source", + "lastContactedAt", + "createdAt", + "updatedAt", + ]); + + const activeSort = table.sorting[0]; + const sortBy = + activeSort && nativeSortableColumns.has(activeSort.id as PeopleListParams["sortBy"]) + ? (activeSort.id as PeopleListParams["sortBy"]) + : undefined; const queryParams: PeopleListParams = { page: table.pagination.pageIndex + 1, pageSize: table.pagination.pageSize, - ...(table.sorting[0] && { - sortBy: table.sorting[0].id as PeopleListParams["sortBy"], - sortOrder: table.sorting[0].desc ? "desc" : "asc", + ...(sortBy && { + sortBy, + sortOrder: activeSort?.desc ? "desc" : "asc", }), - ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(table.debouncedSearch.trim() && { search: table.debouncedSearch.trim() }), ...(statusFilter && { status: statusFilter as PeopleListParams["status"] }), ...(sourceFilter && { source: sourceFilter as PeopleListParams["source"] }), }; - // Data const { data, isLoading, isError, refetch } = usePeople(queryParams); const people = data?.people ?? []; const totalCount = data?.meta.totalCount ?? 0; const pageCount = data?.meta.totalPages ?? 0; - // Mutations const { mutate: deletePerson, isPending: isDeleting } = useDeletePerson(); const { mutate: bulkDelete, isPending: isBulkDeleting } = useBulkDeletePeople(); - // Selected row IDs (row keys come from getRowId which returns person.id) const selectedIds = Object.entries(table.rowSelection) .filter(([, value]) => value) .map(([id]) => id); const selectedCount = selectedIds.length; - function updateTable(next: Partial) { - setTable((current) => ({ ...current, ...next })); - } - - function openDrawer(mode: EntitySheetMode, person?: Person) { - setUi((current) => ({ - ...current, - drawer: { open: true, mode, person }, - })); - } - - function closeDrawer() { - setUi((current) => ({ - ...current, - drawer: { ...current.drawer, open: false }, - })); - } - const columns = getPeopleColumns({ - onView: (person) => openDrawer("view", person), - onEdit: (person) => openDrawer("edit", person), - onDelete: (person) => setUi((current) => ({ ...current, deleteTarget: person })), + onView: (person) => drawer.openDrawer("view", person), + onEdit: (person) => drawer.openDrawer("edit", person), + onDelete: (person) => deleteDialog.openDelete(person), + customFields, }); - // ─── Delete ────────────────────────────────────────────────────────────────── - function handleDeleteConfirm() { - if (ui.deleteTarget === "bulk") { + if (deleteDialog.isBulkDelete) { bulkDelete( { ids: selectedIds }, { onSuccess: () => { - setTable((current) => ({ ...current, rowSelection: {} })); - setUi((current) => ({ ...current, deleteTarget: null })); + table.resetRowSelection(); + deleteDialog.closeDelete(); }, }, ); - } else if (ui.deleteTarget) { - deletePerson(ui.deleteTarget.id, { - onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), + } else if (deleteDialog.deleteTarget && deleteDialog.deleteTarget !== "bulk") { + deletePerson(deleteDialog.deleteTarget.id, { + onSuccess: () => deleteDialog.closeDelete(), }); } } @@ -140,22 +96,20 @@ export function PeopleDataTable() { const isDeletePending = isDeleting || isBulkDeleting; const confirmDialogCopy = - ui.deleteTarget === "bulk" + deleteDialog.deleteTarget && deleteDialog.deleteTarget !== "bulk" ? { - title: `Delete ${selectedCount} ${selectedCount === 1 ? "person" : "people"}?`, - description: `This will permanently remove ${ - selectedCount === 1 ? "this person" : `these ${selectedCount} people` - } from your CRM. This action cannot be undone.`, + title: `Delete "${deleteDialog.deleteTarget.name}"?`, + description: `This will permanently remove ${deleteDialog.deleteTarget.name} from your CRM. This action cannot be undone.`, } - : ui.deleteTarget + : deleteDialog.isBulkDelete ? { - title: `Delete "${ui.deleteTarget.name}"?`, - description: `This will permanently remove ${ui.deleteTarget.name} from your CRM. This action cannot be undone.`, + title: `Delete ${selectedCount} ${selectedCount === 1 ? "person" : "people"}?`, + description: `This will permanently remove ${ + selectedCount === 1 ? "this person" : `these ${selectedCount} people` + } from your CRM. This action cannot be undone.`, } : { title: "", description: "" }; - // ─── Render ────────────────────────────────────────────────────────────────── - return (
openDrawer("create")}> + @@ -176,19 +130,13 @@ export function PeopleDataTable() { pageCount={pageCount} pageIndex={table.pagination.pageIndex} pageSize={table.pagination.pageSize} - onPaginationChange={(pagination) => updateTable({ pagination })} + onPaginationChange={table.onPaginationChange} sorting={table.sorting} - onSortingChange={(next) => { - setTable((current) => ({ - ...current, - sorting: next, - pagination: { ...current.pagination, pageIndex: 0 }, - })); - }} + onSortingChange={table.onSortingChange} columnFilters={table.columnFilters} - onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} + onColumnFiltersChange={table.onColumnFiltersChange} searchValue={table.searchInput} - onSearchChange={(searchInput) => updateTable({ searchInput })} + onSearchChange={table.onSearchChange} searchPlaceholder="Search people…" filterConfig={PEOPLE_FILTER_CONFIG} isLoading={isLoading} @@ -198,9 +146,9 @@ export function PeopleDataTable() { onRetry={refetch} enableRowSelection rowSelection={table.rowSelection} - onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} + onRowSelectionChange={table.onRowSelectionChange} getRowId={(row) => row.id} - onRowClick={(person) => openDrawer("view", person)} + onRowClick={(person) => drawer.openDrawer("view", person)} emptyTitle="No people yet" emptyDescription="Add your first contact to get started." toolbarActions={ @@ -208,7 +156,7 @@ export function PeopleDataTable() { +
+ ))} +
+ +
+
+ ); +} + +interface CustomFieldsManagerUIProps { + entityType: CustomFieldEntity; + fields: CustomFieldDefinition[]; + isLoading: boolean; + isMutating: boolean; + onCreate: (input: CreateCustomFieldDefinitionInput, options?: { onSuccess?: () => void }) => void; + onUpdate: ( + id: string, + input: UpdateCustomFieldDefinitionInput, + options?: { onSuccess?: () => void }, + ) => void; + onDelete: (id: string, options?: { onSuccess?: () => void }) => void; +} + +function CustomFieldsManagerUI({ + entityType, + fields, + isLoading, + isMutating, + onCreate, + onUpdate, + onDelete, +}: CustomFieldsManagerUIProps) { + const [mode, setMode] = useState<"create" | "edit" | "delete" | null>(null); + const [activeField, setActiveField] = useState(null); + + const config = ENTITY_CONFIG[entityType]; + + const createForm = useForm({ + resolver: zodResolver(createCustomFieldDefinitionSchema), + defaultValues: { label: "", type: "text", options: [] }, + }); + + const editForm = useForm({ + resolver: zodResolver(updateCustomFieldDefinitionSchema), + defaultValues: { label: "", options: [] }, + }); + + const openCreate = () => { + setActiveField(null); + createForm.reset({ label: "", type: "text", options: [] }); + setMode("create"); + }; + + const openEdit = (field: CustomFieldDefinition) => { + setActiveField(field); + editForm.reset({ + label: field.label, + options: field.options.map((o) => ({ label: o.label })), + }); + setMode("edit"); + }; + + const closeDialog = () => { + setMode(null); + setActiveField(null); + }; + + const handleCreate = (values: CreateCustomFieldDefinitionInput) => { + const payload: CreateCustomFieldDefinitionInput = { + label: values.label, + type: values.type, + ...(values.type === "select" && { + options: (values.options ?? []).filter((o) => o.label.trim().length > 0), + }), + }; + + onCreate(payload, { + onSuccess: () => { + createForm.reset({ label: "", type: "text", options: [] }); + setMode(null); + }, + }); + }; + + const handleUpdate = (values: UpdateCustomFieldDefinitionInput) => { + if (!activeField) return; + + const payload: UpdateCustomFieldDefinitionInput = { + label: values.label, + }; + + if (activeField.fieldType === "select") { + payload.options = (values.options ?? []).filter((o) => o.label.trim().length > 0); + } + + onUpdate(activeField.id, payload, { + onSuccess: () => { + editForm.reset({ label: "", options: [] }); + setMode(null); + setActiveField(null); + }, + }); + }; + + const handleDelete = () => { + if (!activeField) return; + onDelete(activeField.id, { onSuccess: () => closeDialog() }); + }; + + const sortedFields = [...fields].sort((a, b) => a.label.localeCompare(b.label)); + + const Icon = config.icon; + // eslint-disable-next-line react-hooks/incompatible-library + const createType = createForm.watch("type"); + + return ( +
+
+
+
+ +
+
+

{config.label} custom fields

+

+ Manage custom fields for {config.label.toLowerCase()}. +

+
+
+ +
+ +
+ + + + Label + Type + Options + Actions + + + + {isLoading ? ( + + + Loading custom fields… + + + ) : sortedFields.length === 0 ? ( + + + No custom fields defined yet. + + + ) : ( + sortedFields.map((field) => ( + + {field.label} + + + {typeLabel(field.fieldType)} + + + + {field.fieldType === "select" ? ( +
+ {field.options.map((option) => ( + + {option.label} + + ))} + {field.options.length === 0 && ( + No options + )} +
+ ) : ( + + )} +
+ +
+ + +
+
+
+ )) + )} +
+
+
+ + {/* Create / Edit Dialog */} + !open && closeDialog()}> + + + {mode === "edit" ? "Edit custom field" : "Create custom field"} + + {mode === "edit" + ? "Update the label and options for this field." + : `Add a new custom field to ${config.label.toLowerCase()}.`} + + + + {mode === "edit" ? ( +
+ + ( + + Field label + + + + + + )} + /> + +
+ Type is fixed:{" "} + + {typeLabel(activeField!.fieldType)} + +
+ + {activeField!.fieldType === "select" && ( + ( + + Options + + + + + + )} + /> + )} + + + + + + + + ) : ( +
+ + ( + + Field label + + + + + + )} + /> + + ( + + Type + + + + )} + /> + + {createType === "select" && ( + ( + + Options + + + + + + )} + /> + )} + + + + + + + + )} +
+
+ + {/* Delete Confirmation Dialog */} + !open && closeDialog()}> + + + Delete custom field + + Are you sure you want to delete {activeField?.label}? This action + cannot be undone and will remove all associated data. + + + + + + + + +
+ ); +} + +export function PeopleCustomFieldsManager() { + const { data: fields = [], isLoading } = usePeopleCustomFields(); + const { mutate: create, isPending: isCreatePending } = useCreatePeopleCustomField(); + const { mutate: update, isPending: isUpdatePending } = useUpdatePeopleCustomField(); + const { mutate: delete_, isPending: isDeletePending } = useDeletePeopleCustomField(); + + const isMutating = isCreatePending || isUpdatePending || isDeletePending; + + return ( + create(input, opts)} + onUpdate={(id, input, opts) => update({ id, input }, opts)} + onDelete={(id, opts) => delete_(id, opts)} + /> + ); +} + +export function OrgCustomFieldsManager() { + const { data: fields = [], isLoading } = useOrgCustomFields(); + const { mutate: create, isPending: isCreatePending } = useCreateOrgCustomField(); + const { mutate: update, isPending: isUpdatePending } = useUpdateOrgCustomField(); + const { mutate: delete_, isPending: isDeletePending } = useDeleteOrgCustomField(); + + const isMutating = isCreatePending || isUpdatePending || isDeletePending; + + return ( + create(input, opts)} + onUpdate={(id, input, opts) => update({ id, input }, opts)} + onDelete={(id, opts) => delete_(id, opts)} + /> + ); +} diff --git a/apps/web/components/workspace/settings/general-settings.tsx b/apps/web/components/workspace/settings/general-settings.tsx index 1ba15da..ca9b829 100644 --- a/apps/web/components/workspace/settings/general-settings.tsx +++ b/apps/web/components/workspace/settings/general-settings.tsx @@ -65,7 +65,7 @@ export function GeneralSettings({ workspace }: GeneralSettingsProps) { deleteWorkspaceMutation(workspace.id, { onSuccess: () => { setDialogState("idle"); - router.push("/onboarding"); + router.push("/dashboard"); }, }); } @@ -74,7 +74,7 @@ export function GeneralSettings({ workspace }: GeneralSettingsProps) { leaveWorkspaceMutation(workspace.id, { onSuccess: () => { setDialogState("idle"); - router.push("/onboarding"); + router.push("/dashboard"); }, }); } @@ -179,45 +179,41 @@ export function GeneralSettings({ workspace }: GeneralSettingsProps) {
- {!isOwner && ( -
-
-

Leave workspace

-

- You will lose access to all workspace data. -

-
- +
+
+

Leave workspace

+

+ You will lose access to all workspace data. +

- )} + +
- {isOwner && ( -
-
-

Delete workspace

-

- Permanently delete this workspace and all its data. -

-
- +
+
+

Delete workspace

+

+ Permanently delete this workspace and all its data. +

- )} + +
diff --git a/apps/web/components/workspace/settings/invitations-settings.tsx b/apps/web/components/workspace/settings/invitations-settings.tsx index 51cdb01..6446416 100644 --- a/apps/web/components/workspace/settings/invitations-settings.tsx +++ b/apps/web/components/workspace/settings/invitations-settings.tsx @@ -15,22 +15,19 @@ import { Badge } from "@workspace/ui/components/ui/badge"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { EmptyState } from "@/components/shared/empty-state"; import { InviteMemberDialog } from "@/components/workspace/invite-member-dialog"; +import { useAuthSession } from "@/hooks/queries/use-auth"; import { useCancelInvitation } from "@/hooks/queries/use-workspace"; import type { WorkspaceInvitation, InvitationsSettingsProps } from "@/types/workspace-settings"; -import { WORKSPACE_ROLE } from "@workspace/validators/schemas/common"; -export function InvitationsSettings({ - invitations, - organizationId, - currentUserRole, -}: InvitationsSettingsProps) { +export function InvitationsSettings({ invitations }: InvitationsSettingsProps) { + const { data: session } = useAuthSession(); + const organizationId = session?.session?.activeOrganizationId ?? undefined; + const { mutate: cancelInvitationMutation, isPending: isCancelPending } = useCancelInvitation(); const [inviteOpen, setInviteOpen] = useState(false); const [cancelTarget, setCancelTarget] = useState(null); - const canInvite = - currentUserRole === WORKSPACE_ROLE.owner || currentUserRole === WORKSPACE_ROLE.admin; const pendingInvitations = invitations.filter((invitation) => invitation.status === "pending"); function handleCancel() { @@ -51,12 +48,10 @@ export function InvitationsSettings({ {pendingInvitations.length === 1 ? "invite" : "invites"}.

- {canInvite && ( - - )} +
{pendingInvitations.length === 0 ? ( @@ -64,9 +59,7 @@ export function InvitationsSettings({ icon={Mail} title="No pending invitations" description="Invite team members to collaborate in this workspace." - action={ - canInvite ? { label: "Invite member", onClick: () => setInviteOpen(true) } : undefined - } + action={{ label: "Invite member", onClick: () => setInviteOpen(true) }} className="rounded-lg border border-dashed border-border bg-muted/20" /> ) : ( @@ -77,7 +70,7 @@ export function InvitationsSettings({ Email Role Expires - {canInvite && } + @@ -92,19 +85,17 @@ export function InvitationsSettings({ {new Date(invitation.expiresAt).toLocaleDateString()} - {canInvite && ( - - - - )} + + + ))} @@ -115,7 +106,7 @@ export function InvitationsSettings({ (null); const currentUserId = session?.user?.id; - const currentMember = members.find((member) => member.userId === currentUserId); - const canManage = - currentMember?.role === WORKSPACE_ROLE.owner || currentMember?.role === WORKSPACE_ROLE.admin; function handleRoleChange(memberId: string, role: AssignableWorkspaceRole) { updateRoleMutation({ memberId, role }); @@ -90,15 +89,13 @@ export function MembersSettings({ members, organizationId, ownerId }: MembersSet User Role Joined - {canManage && } + {members.map((member) => { const isCurrentUser = member.userId === currentUserId; const isOwner = member.userId === ownerId; - const canEditRole = canManage && !isOwner && !isCurrentUser; - const canRemove = canManage && !isOwner && !isCurrentUser; return ( @@ -126,7 +123,14 @@ export function MembersSettings({ members, organizationId, ownerId }: MembersSet
- {canEditRole ? ( + {isOwner ? ( + + {member.role} + + ) : ( - ) : ( - - {member.role} - )} {new Date(member.createdAt).toLocaleDateString()} - {canManage && ( + {!isOwner && ( - {canRemove && ( - - )} + )} diff --git a/apps/web/constants/custom-fields.ts b/apps/web/constants/custom-fields.ts new file mode 100644 index 0000000..4f77127 --- /dev/null +++ b/apps/web/constants/custom-fields.ts @@ -0,0 +1,38 @@ +import { Users, Building2 } from "lucide-react"; +import type { CustomFieldType } from "@/types/crm"; + +export type CustomFieldEntity = "people" | "org"; + +export const CUSTOM_FIELD_TYPE_OPTIONS: Array<{ label: string; value: CustomFieldType }> = [ + { label: "Text", value: "text" }, + { label: "Number", value: "number" }, + { label: "Select", value: "select" }, + { label: "Date & Time", value: "dateTime" }, +]; + +export const ENTITY_CONFIG: Record< + CustomFieldEntity, + { label: string; icon: typeof Users } +> = { + people: { label: "People", icon: Users }, + org: { label: "Organizations", icon: Building2 }, +}; + +export function typeBadgeVariant(type: CustomFieldType): "default" | "secondary" | "outline" | "destructive" { + switch (type) { + case "text": + return "default"; + case "number": + return "secondary"; + case "select": + return "outline"; + case "dateTime": + return "destructive"; + default: + return "default"; + } +} + +export function typeLabel(type: CustomFieldType) { + return CUSTOM_FIELD_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type; +} diff --git a/apps/web/constants/navigation.ts b/apps/web/constants/navigation.ts index 0b29506..c10a8d9 100644 --- a/apps/web/constants/navigation.ts +++ b/apps/web/constants/navigation.ts @@ -1,4 +1,13 @@ -import { Building2, Kanban, LayoutDashboard, Mail, Settings, Settings2, Users } from "lucide-react"; +import { + Building2, + Kanban, + LayoutDashboard, + Mail, + Settings, + Settings2, + Users, + FormInput, +} from "lucide-react"; export const MAIN_NAV_ITEMS = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, @@ -13,6 +22,7 @@ export const WORKSPACE_NAV_ITEMS = [ export const SETTINGS_NAV_ITEMS = [ { id: "general", label: "General", icon: Settings2 }, + { id: "custom-fields", label: "Custom fields", icon: FormInput }, { id: "members", label: "Members", icon: Users }, { id: "invitations", label: "Invitations", icon: Mail }, ] as const; diff --git a/apps/web/hooks/queries/use-crm-custom-fields.ts b/apps/web/hooks/queries/use-crm-custom-fields.ts new file mode 100644 index 0000000..26ff593 --- /dev/null +++ b/apps/web/hooks/queries/use-crm-custom-fields.ts @@ -0,0 +1,148 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { QUERY_KEYS } from "@/lib/query-keys"; +import { useAuthSession } from "@/hooks/queries/use-auth"; +import { + createOrgCustomField, + createPeopleCustomField, + deleteOrgCustomField, + deletePeopleCustomField, + listOrgCustomFields, + listPeopleCustomFields, + updateOrgCustomField, + updatePeopleCustomField, +} from "@/services/crm/custom-fields.service"; +import type { + CreateCustomFieldDefinition, + CustomFieldDefinition, + UpdateCustomFieldDefinition, +} from "@/types/crm"; + +function invalidateCrmCaches(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); +} + +export function usePeopleCustomFields(options?: { enabled?: boolean }) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_CUSTOM_FIELDS], + queryFn: listPeopleCustomFields, + enabled: !!session?.user && (options?.enabled ?? true), + placeholderData: (previousData) => previousData, + }); +} + +export function useOrgCustomFields(options?: { enabled?: boolean }) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_CUSTOM_FIELDS], + queryFn: listOrgCustomFields, + enabled: !!session?.user && (options?.enabled ?? true), + placeholderData: (previousData) => previousData, + }); +} + +export function useCreatePeopleCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateCustomFieldDefinition) => createPeopleCustomField(input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_CUSTOM_FIELDS], + }); + invalidateCrmCaches(queryClient); + toast.success("Custom field created", { + description: "The people custom field has been created.", + }); + }, + }); +} + +export function useCreateOrgCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateCustomFieldDefinition) => createOrgCustomField(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_CUSTOM_FIELDS] }); + invalidateCrmCaches(queryClient); + toast.success("Custom field created", { + description: "The organization custom field has been created.", + }); + }, + }); +} + +export function useUpdatePeopleCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateCustomFieldDefinition }) => + updatePeopleCustomField(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_CUSTOM_FIELDS], + }); + invalidateCrmCaches(queryClient); + toast.success("Custom field updated", { + description: "The people custom field has been updated.", + }); + }, + }); +} + +export function useUpdateOrgCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateCustomFieldDefinition }) => + updateOrgCustomField(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_CUSTOM_FIELDS] }); + invalidateCrmCaches(queryClient); + toast.success("Custom field updated", { + description: "The organization custom field has been updated.", + }); + }, + }); +} + +export function useDeletePeopleCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deletePeopleCustomField(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_CUSTOM_FIELDS], + }); + invalidateCrmCaches(queryClient); + toast.success("Custom field deleted", { + description: "The people custom field has been deleted.", + }); + }, + }); +} + +export function useDeleteOrgCustomField() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteOrgCustomField(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_CUSTOM_FIELDS] }); + invalidateCrmCaches(queryClient); + toast.success("Custom field deleted", { + description: "The organization custom field has been deleted.", + }); + }, + }); +} + +export function mapCustomFieldsById(customFields: CustomFieldDefinition[]) { + return new Map(customFields.map((field) => [field.id, field])); +} diff --git a/apps/web/hooks/queries/use-orgs.ts b/apps/web/hooks/queries/use-org.ts similarity index 98% rename from apps/web/hooks/queries/use-orgs.ts rename to apps/web/hooks/queries/use-org.ts index e5c1654..5d1087d 100644 --- a/apps/web/hooks/queries/use-orgs.ts +++ b/apps/web/hooks/queries/use-org.ts @@ -9,7 +9,7 @@ import { getOrganization, listOrganizations, updateOrganization, -} from "@/services/crm/orgs.service"; +} from "@/services/crm/org.service"; import type { BulkDeleteInput, CreateOrganizationInput, diff --git a/apps/web/hooks/queries/use-workspace.ts b/apps/web/hooks/queries/use-workspace.ts index 357c22c..1c7805f 100644 --- a/apps/web/hooks/queries/use-workspace.ts +++ b/apps/web/hooks/queries/use-workspace.ts @@ -11,6 +11,8 @@ import { inviteMember, leaveWorkspace, listWorkspaces, + listWorkspaceInvitations, + listWorkspaceMembers, rejectInvitation, removeMember, setActiveWorkspace, @@ -24,6 +26,7 @@ import type { import type { CreateWorkspace, UpdateWorkspace } from "@workspace/validators/schemas/workspace"; import { QUERY_KEYS } from "@/lib/query-keys"; import { useAuthSession } from "@/hooks/queries/use-auth"; +import type { WorkspaceInvitation, WorkspaceMember } from "@/types/workspace-settings"; export function useWorkspaces() { const { data: session } = useAuthSession(); @@ -68,6 +71,30 @@ export function useInvitation(invitationId: string | null) { }); } +export function useWorkspaceMembers( + opts: { organizationId?: string; limit?: number; offset?: number } = {}, +) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.WORKSPACES, QUERY_KEYS.WORKSPACE, "members", opts], + queryFn: () => listWorkspaceMembers(opts) as Promise, + enabled: !!session?.user, + placeholderData: (prev) => prev, + }); +} + +export function useWorkspaceInvitations(opts: { organizationId?: string } = {}) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.WORKSPACES, QUERY_KEYS.WORKSPACE_INVITATIONS, opts], + queryFn: () => listWorkspaceInvitations(opts) as Promise, + enabled: !!session?.user, + placeholderData: (prev) => prev, + }); +} + export function useCreateWorkspace() { const qc = useQueryClient(); diff --git a/apps/web/hooks/use-data-table-state.ts b/apps/web/hooks/use-data-table-state.ts new file mode 100644 index 0000000..bc7baf0 --- /dev/null +++ b/apps/web/hooks/use-data-table-state.ts @@ -0,0 +1,80 @@ +import { useState, useCallback } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import type { + ColumnFiltersState, + PaginationState, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; + +export interface DataTableState { + pagination: PaginationState; + sorting: SortingState; + columnFilters: ColumnFiltersState; + rowSelection: RowSelectionState; + searchInput: string; +} + +interface UseDataTableStateOptions { + defaultPageSize?: number; + debounceMs?: number; +} + +export function useDataTableState(options: UseDataTableStateOptions = {}) { + const { defaultPageSize = 50, debounceMs = 300 } = options; + + const [state, setState] = useState({ + pagination: { pageIndex: 0, pageSize: defaultPageSize }, + sorting: [], + columnFilters: [], + rowSelection: {}, + searchInput: "", + }); + + const [debouncedSearch] = useDebounceValue(state.searchInput, debounceMs); + + const onPaginationChange = useCallback((pagination: PaginationState) => { + setState((current) => ({ ...current, pagination })); + }, []); + + const onSortingChange = useCallback((sorting: SortingState) => { + setState((current) => ({ + ...current, + sorting, + pagination: { ...current.pagination, pageIndex: 0 }, // Reset to first page on sort change + })); + }, []); + + const onColumnFiltersChange = useCallback((columnFilters: ColumnFiltersState) => { + setState((current) => ({ ...current, columnFilters })); + }, []); + + const onSearchChange = useCallback((searchInput: string) => { + setState((current) => ({ ...current, searchInput })); + }, []); + + const onRowSelectionChange = useCallback((rowSelection: RowSelectionState) => { + setState((current) => ({ ...current, rowSelection })); + }, []); + + const resetRowSelection = useCallback(() => { + setState((current) => ({ ...current, rowSelection: {} })); + }, []); + + const getFilterValue = useCallback( + (columnId: string) => state.columnFilters.find((f) => f.id === columnId)?.value, + [state.columnFilters], + ); + + return { + ...state, + debouncedSearch, + onPaginationChange, + onSortingChange, + onColumnFiltersChange, + onSearchChange, + onRowSelectionChange, + resetRowSelection, + getFilterValue, + }; +} diff --git a/apps/web/hooks/use-entity-delete.ts b/apps/web/hooks/use-entity-delete.ts new file mode 100644 index 0000000..139365d --- /dev/null +++ b/apps/web/hooks/use-entity-delete.ts @@ -0,0 +1,33 @@ +import { useState, useCallback } from "react"; + +export function useEntityDelete(options?: { + enableBulkDelete?: boolean; +}) { + const { enableBulkDelete = false } = options ?? {}; + const [deleteTarget, setDeleteTarget] = useState(null); + + const openDelete = useCallback((entity: TEntity) => { + setDeleteTarget(entity); + }, []); + + const openBulkDelete = useCallback(() => { + if (!enableBulkDelete) return; + setDeleteTarget("bulk"); + }, [enableBulkDelete]); + + const closeDelete = useCallback(() => { + setDeleteTarget(null); + }, []); + + const isBulkDelete = deleteTarget === "bulk"; + const isEntityDelete = deleteTarget !== null && deleteTarget !== "bulk"; + + return { + deleteTarget, + openDelete, + openBulkDelete, + closeDelete, + isBulkDelete, + isEntityDelete, + }; +} diff --git a/apps/web/hooks/use-entity-drawer.ts b/apps/web/hooks/use-entity-drawer.ts new file mode 100644 index 0000000..29351d8 --- /dev/null +++ b/apps/web/hooks/use-entity-drawer.ts @@ -0,0 +1,35 @@ +import { useState, useCallback } from "react"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; + +export interface EntityDrawerState { + // t with enity name attached to avoid confusion with other open states in the app + open: boolean; + mode: EntitySheetMode; + entity?: TEntity; +} + +export function useEntityDrawer() { + const [drawer, setDrawer] = useState>({ + open: false, + mode: "view", + }); + + const openDrawer = useCallback((mode: EntitySheetMode, entity?: TEntity) => { + setDrawer({ open: true, mode, entity }); + }, []); + + const onDrawerOpenChange = useCallback((open: boolean) => { + setDrawer((current) => ({ ...current, open })); + }, []); + + const onDrawerModeChange = useCallback((mode: EntitySheetMode) => { + setDrawer((current) => ({ ...current, mode })); + }, []); + + return { + drawer, + openDrawer, + onDrawerOpenChange, + onDrawerModeChange, + }; +} diff --git a/apps/web/lib/crm-custom-fields.tsx b/apps/web/lib/crm-custom-fields.tsx new file mode 100644 index 0000000..410724b --- /dev/null +++ b/apps/web/lib/crm-custom-fields.tsx @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import type { CustomFieldDefinition } from "@/types/crm"; + +type CrmEntity = { customFields?: Record | null }; + +function getCustomFieldColumnId(fieldId: string) { + return `custom:${fieldId}`; +} + +export function buildCustomFieldColumns( + fields: CustomFieldDefinition[], +): ColumnDef[] { + return fields.map((field) => ({ + id: getCustomFieldColumnId(field.id), + header: field.label, + enableSorting: true, + cell: ({ row }) => { + const customValue = row.original.customFields?.[field.id]; + if (customValue === undefined || customValue === null || customValue === "") { + return -; + } + + if (field.fieldType === "select") { + const option = field.options.find((item) => item.id === String(customValue)); + return {option?.label ?? "-"}; + } + + if (field.fieldType === "dateTime") { + const date = dayjs(String(customValue)); + return {date.isValid() ? date.format("MMM D, YYYY") : "-"}; + } + + return {String(customValue)}; + }, + })); +} + +export function buildCustomFieldsPayload( + customFields: CustomFieldDefinition[], + values: Record, +): Record { + const payload: Record = {}; + + for (const field of customFields) { + const rawValue = values[field.id]; + + if (field.fieldType === "text") { + const textValue = typeof rawValue === "string" ? rawValue.trim() : ""; + if (textValue) { + payload[field.id] = textValue; + } + continue; + } + + if (field.fieldType === "number") { + const normalizedValue = + typeof rawValue === "number" + ? rawValue + : typeof rawValue === "string" + ? Number(rawValue) + : Number.NaN; + + if (!Number.isNaN(normalizedValue)) { + payload[field.id] = normalizedValue; + } + continue; + } + + if (field.fieldType === "select") { + const selectedOptionId = typeof rawValue === "string" ? rawValue : ""; + if (selectedOptionId) { + payload[field.id] = selectedOptionId; + } + continue; + } + + if (field.fieldType === "dateTime") { + const dateValue = typeof rawValue === "string" ? rawValue : ""; + const parsed = dayjs(dateValue); + if (dateValue && parsed.isValid()) { + payload[field.id] = parsed.format("YYYY-MM-DDTHH:mm:ss"); + } + } + } + + return payload; +} + +export function toDateTimeInputValue(value: unknown) { + if (typeof value !== "string" || !value) { + return ""; + } + + const parsed = dayjs(value); + if (!parsed.isValid()) { + return ""; + } + + return parsed.format("YYYY-MM-DDTHH:mm"); +} + +export function formatCustomFieldValueForView( + field: CustomFieldDefinition, + value: unknown, +): string { + if (value === undefined || value === null || value === "") { + return "Not set"; + } + + if (field.fieldType === "select") { + const option = field.options.find((item) => item.id === String(value)); + return option?.label ?? "Not set"; + } + + if (field.fieldType === "dateTime") { + const date = dayjs(String(value)); + return date.isValid() ? date.format("MMMM D, YYYY h:mm A") : "Not set"; + } + + return String(value); +} diff --git a/apps/web/lib/data-table-utils.ts b/apps/web/lib/data-table-utils.ts new file mode 100644 index 0000000..d104387 --- /dev/null +++ b/apps/web/lib/data-table-utils.ts @@ -0,0 +1,36 @@ +export function formatColumnLabel(id: string) { + return id + .replace(/^_+/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[-_]/g, " ") + .trim(); +} + +export function getColumnLabel(id: string, header: unknown) { + if (typeof header === "string") { + return header; + } + return formatColumnLabel(id); +} + +export function getVisiblePageNumbers( + pageIndex: number, + pageCount: number, +): Array { + if (pageCount <= 0) return []; + if (pageCount <= 7) return Array.from({ length: pageCount }, (_, i) => i + 1); + + const current = pageIndex + 1; + const pages: Array = [1]; + + if (current > 3) pages.push("ellipsis"); + + const start = Math.max(2, current - 1); + const end = Math.min(pageCount - 1, current + 1); + for (let p = start; p <= end; p++) pages.push(p); + + if (current < pageCount - 2) pages.push("ellipsis"); + + pages.push(pageCount); + return pages; +} diff --git a/apps/web/lib/error.ts b/apps/web/lib/error.ts index 2c8f80d..70edd8f 100644 --- a/apps/web/lib/error.ts +++ b/apps/web/lib/error.ts @@ -42,28 +42,38 @@ export function toBetterAuthError( }; } +function getFriendlyErrorMessage(status: number | undefined, rawMessage: string): string { + if (status === 403) return "You don't have permission to do that"; + if (status === 401) return "Please sign in again"; + if (status === 404) return "Resource not found"; + if (status && status >= 500) return "Something went wrong. Please try again."; + return rawMessage; +} + export function normalizeAppError(error: unknown): AppError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; const responseData = axiosError.response?.data; const nestedError = responseData && "error" in responseData ? responseData.error : undefined; - const message = + const rawMessage = nestedError?.message ?? (responseData && "message" in responseData ? responseData.message : undefined) ?? axiosError.message ?? "Request failed"; + const status = axiosError.response?.status; return { details: nestedError?.details as string | undefined, - message, - status: axiosError.response?.status, + message: getFriendlyErrorMessage(status, rawMessage), + status, }; } if (isBetterAuthError(error)) { + const rawMessage = error.message ?? error.statusText ?? "Request failed"; return { code: error.code, - message: error.message ?? error.statusText ?? "Request failed", + message: getFriendlyErrorMessage(error.status, rawMessage), status: error.status, }; } diff --git a/apps/web/lib/query-keys.ts b/apps/web/lib/query-keys.ts index 8603e84..37de129 100644 --- a/apps/web/lib/query-keys.ts +++ b/apps/web/lib/query-keys.ts @@ -11,9 +11,11 @@ export const QUERY_KEYS = { PEOPLE: "people", PEOPLE_LIST: "people-list", PEOPLE_DETAIL: "people-detail", - ORGS: "orgs", - ORGS_LIST: "orgs-list", - ORGS_DETAIL: "orgs-detail", + PEOPLE_CUSTOM_FIELDS: "people-custom-fields", + ORGS: "org", + ORGS_LIST: "org-list", + ORGS_DETAIL: "org-detail", + ORGS_CUSTOM_FIELDS: "org-custom-fields", DEALS: "deals", DEALS_LIST: "deals-list", DEALS_DETAIL: "deals-detail", diff --git a/apps/web/services/crm/custom-fields.service.ts b/apps/web/services/crm/custom-fields.service.ts new file mode 100644 index 0000000..f514662 --- /dev/null +++ b/apps/web/services/crm/custom-fields.service.ts @@ -0,0 +1,75 @@ +import { apiClient } from "@/lib/axios-client"; +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; +import type { + CreateCustomFieldDefinition, + CustomFieldDefinition, + CustomFieldDefinitionsResponse, + UpdateCustomFieldDefinition, +} from "@/types/crm"; + +type CustomFieldDefinitionResponse = ApiSuccessResponse<{ customField: CustomFieldDefinition }>; +type CustomFieldsResponse = ApiSuccessResponse; + +export async function listPeopleCustomFields() { + const response = await apiClient.get("/people/custom-fields"); + const { data } = response.data; + return data.customFields; +} + +export async function listOrgCustomFields() { + const response = await apiClient.get("/org/custom-fields"); + const { data } = response.data; + return data.customFields; +} + +export async function createPeopleCustomField(input: CreateCustomFieldDefinition) { + const response = await apiClient.post( + "/people/custom-fields", + input, + ); + const { data } = response.data; + return data.customField; +} + +export async function createOrgCustomField(input: CreateCustomFieldDefinition) { + const response = await apiClient.post( + "/org/custom-fields", + input, + ); + const { data } = response.data; + return data.customField; +} + +export async function updatePeopleCustomField(id: string, input: UpdateCustomFieldDefinition) { + const response = await apiClient.patch( + `/people/custom-fields/${id}`, + input, + ); + const { data } = response.data; + return data.customField; +} + +export async function updateOrgCustomField(id: string, input: UpdateCustomFieldDefinition) { + const response = await apiClient.patch( + `/org/custom-fields/${id}`, + input, + ); + const { data } = response.data; + return data.customField; +} + +export async function deletePeopleCustomField(id: string) { + const response = await apiClient.delete( + `/people/custom-fields/${id}`, + ); + const { data } = response.data; + return data.customField; +} + +export async function deleteOrgCustomField(id: string) { + const response = await apiClient.delete( + `/org/custom-fields/${id}`, + ); + const { data } = response.data; + return data.customField; +} diff --git a/apps/web/services/crm/orgs.service.ts b/apps/web/services/crm/org.service.ts similarity index 92% rename from apps/web/services/crm/orgs.service.ts rename to apps/web/services/crm/org.service.ts index 8e76443..560d17e 100644 --- a/apps/web/services/crm/orgs.service.ts +++ b/apps/web/services/crm/org.service.ts @@ -15,7 +15,7 @@ type OrganizationResponse = ApiSuccessResponse<{ }>; export async function listOrganizations(params: Partial = {}) { - const response = await apiClient.get>("/orgs", { + const response = await apiClient.get>("/org", { params: cleanQueryParams(params), }); @@ -24,31 +24,31 @@ export async function listOrganizations(params: Partial = {}) { } export async function getOrganization(id: OrgParams["id"]) { - const response = await apiClient.get(`/orgs/${id}`); + const response = await apiClient.get(`/org/${id}`); const { data } = response.data; return data.org; } export async function createOrganization(input: CreateOrg) { - const response = await apiClient.post("/orgs", input); + const response = await apiClient.post("/org", input); const { data } = response.data; return data.org; } export async function updateOrganization(id: OrgParams["id"], input: UpdateOrg) { - const response = await apiClient.patch(`/orgs/${id}`, input); + const response = await apiClient.patch(`/org/${id}`, input); const { data } = response.data; return data.org; } export async function deleteOrganization(id: OrgParams["id"]) { - const response = await apiClient.delete(`/orgs/${id}`); + const response = await apiClient.delete(`/org/${id}`); const { data } = response.data; return data.org; } export async function bulkDeleteOrganizations(input: BulkDeleteInput) { - const response = await apiClient.delete>("/orgs/bulk", { + const response = await apiClient.delete>("/org/bulk", { data: input, }); diff --git a/apps/web/services/workspace.service.ts b/apps/web/services/workspace.service.ts index ce22a5d..299277c 100644 --- a/apps/web/services/workspace.service.ts +++ b/apps/web/services/workspace.service.ts @@ -34,6 +34,31 @@ export async function getFullWorkspace( return data; } +export async function listWorkspaceMembers( + opts: { organizationId?: string; limit?: number; offset?: number } = {}, +) { + const { data, error } = await authClient.organization.listMembers({ + query: opts, + }); + if (error) throw toBetterAuthError(error, "Failed to fetch members"); + // Normalize: Better Auth may return { members: [...] } or [...] directly + const members = Array.isArray(data) + ? data + : ((data as { members?: unknown[] } | null)?.members ?? []); + return members; +} + +export async function listWorkspaceInvitations(opts: { organizationId?: string } = {}) { + const { data, error } = await authClient.organization.listInvitations({ + query: opts, + }); + if (error) throw toBetterAuthError(error, "Failed to fetch invitations"); + const invitations = Array.isArray(data) + ? data + : ((data as { invitations?: unknown[] } | null)?.invitations ?? []); + return invitations; +} + export async function updateWorkspace(organizationId: string, input: UpdateWorkspace) { const { data, error } = await authClient.organization.update({ organizationId, diff --git a/apps/web/types/crm.ts b/apps/web/types/crm.ts index ec73422..18c67a6 100644 --- a/apps/web/types/crm.ts +++ b/apps/web/types/crm.ts @@ -1,18 +1,24 @@ import type { + CreateCustomFieldDefinitionInput, CreateDeal, CreateOrg, CreatePerson, + CustomFieldOption, + CustomFieldType as ValidatorCustomFieldType, DealStage, ListDealsQuery, ListOrgsQuery, ListPeopleQuery, PersonSource, PersonStatus, + UpdateCustomFieldDefinitionInput, UpdateDeal, UpdateOrg, UpdatePerson, } from "@workspace/validators/schemas/crm"; +export type CustomFieldType = ValidatorCustomFieldType; + export interface PaginationMeta { page: number; pageSize: number; @@ -75,8 +81,12 @@ export interface Deal { closeDate: string | null; createdAt: string; updatedAt: string; - person?: RelatedEntityRef | null; + personName?: string | null; + orgName?: string | null; + ownerName?: string | null; + people?: RelatedEntityRef[]; org?: RelatedEntityRef | null; + person?: RelatedEntityRef | null; owner?: RelatedEntityRef | null; } @@ -86,7 +96,7 @@ export interface PeopleListResponse { } export interface OrganizationsListResponse { - orgs: Organization[]; + org: Organization[]; meta: PaginationMeta; } @@ -111,6 +121,31 @@ export interface BulkDeleteResponse { deleted: number; } +export interface CustomFieldDefinition { + id: string; + workspaceId: string; + entityType: "people" | "org"; + fieldType: CustomFieldType; + label: string; + options: CustomFieldOption[]; + createdAt: string; + updatedAt: string; +} + +export interface CustomFieldDefinitionsResponse { + customFields: CustomFieldDefinition[]; +} + +export type CustomFieldFilterType = "text" | "number" | "select" | "dateTime"; + +export type CustomFieldFilterValue = { + fieldId: string; + type: CustomFieldFilterType; + value?: string; + from?: string; + to?: string; +}; + export type PeopleListParams = Partial; export type OrganizationsListParams = Partial; export type DealsListParams = Partial; @@ -127,3 +162,6 @@ export type UpdateDealInput = UpdateDeal; export interface BulkDeleteInput { ids: string[]; } + +export type CreateCustomFieldDefinition = CreateCustomFieldDefinitionInput; +export type UpdateCustomFieldDefinition = UpdateCustomFieldDefinitionInput; diff --git a/apps/web/types/workspace-settings.ts b/apps/web/types/workspace-settings.ts index d7d499c..c02450c 100644 --- a/apps/web/types/workspace-settings.ts +++ b/apps/web/types/workspace-settings.ts @@ -30,14 +30,11 @@ export interface WorkspaceInvitation { export interface MembersSettingsProps { members: WorkspaceMember[]; - organizationId: string; ownerId?: string; } export interface InvitationsSettingsProps { invitations: WorkspaceInvitation[]; - organizationId: string; - currentUserRole?: WorkspaceRole; } export interface GeneralSettingsProps { diff --git a/packages/validators/src/schemas/crm.validator.ts b/packages/validators/src/schemas/crm.validator.ts index d6d8234..669c5d1 100644 --- a/packages/validators/src/schemas/crm.validator.ts +++ b/packages/validators/src/schemas/crm.validator.ts @@ -1,6 +1,8 @@ -import { z } from "zod"; +import { custom, z } from "zod"; import { dateLikeSchema, idSchema, nullableUuidSchema } from "./common.validator.js"; import { + CUSTOM_FIELD_ENTITY_TYPE_VALUES, + CUSTOM_FIELD_TYPE_VALUES, DEAL_SORT_BY_VALUES, DEAL_STAGE_VALUES, ORG_SORT_BY_VALUES, @@ -21,8 +23,17 @@ export const sortOrderSchema = z.enum(SORT_ORDER_VALUES); export const orgSortBySchema = z.enum(ORG_SORT_BY_VALUES); export const personSortBySchema = z.enum(PERSON_SORT_BY_VALUES); export const dealSortBySchema = z.enum(DEAL_SORT_BY_VALUES); +export const customFieldEntityTypeSchema = z.enum(CUSTOM_FIELD_ENTITY_TYPE_VALUES); +export const customFieldTypeSchema = z.enum(CUSTOM_FIELD_TYPE_VALUES); -// Base query schema for listing orgs, people, and deals +export const customFieldOptionSchema = z.object({ + id: z.string().uuid(), + label: z.string().trim().min(1).max(255), +}); + +export const customFieldOptionInputSchema = customFieldOptionSchema.omit({ id: true }); + +// Base query schema for listing org, people, and deals export const crmListQueryBaseSchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), @@ -98,10 +109,36 @@ export const createDealSchema = z.object({ export const updateDealSchema = createDealSchema.partial(); export const dealParamsSchema = z.object({ id: idSchema }); +export const customFieldParamsSchema = z.object({ id: idSchema }); + +export const createCustomFieldDefinitionSchema = z + .object({ + label: z.string().trim().min(1).max(255), + type: customFieldTypeSchema, + options: z.array(customFieldOptionInputSchema).optional(), + }) + .superRefine((value, ctx) => { + if (value.type === "select" && (!value.options || value.options.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select fields must include at least one option", + path: ["options"], + }); + } + }); + +export const updateCustomFieldDefinitionSchema = z.object({ + label: z.string().trim().min(1).max(255).optional(), + options: z.array(customFieldOptionInputSchema).optional(), +}); + export type PersonStatus = z.infer; export type PersonSource = z.infer; export type DealStage = z.infer; export type SortOrder = z.infer; +export type CustomFieldEntityType = z.infer; +export type CustomFieldType = z.infer; +export type CustomFieldOption = z.infer; export type CreateOrg = z.infer; export type UpdateOrg = z.infer; @@ -118,4 +155,8 @@ export type UpdateDeal = z.infer; export type DealParams = z.infer; export type ListDealsQuery = z.infer; +export type CustomFieldParams = z.infer; +export type CreateCustomFieldDefinitionInput = z.infer; +export type UpdateCustomFieldDefinitionInput = z.infer; + export type BulkDeleteInput = z.infer; diff --git a/packages/validators/src/types/crm.types.ts b/packages/validators/src/types/crm.types.ts index bb03010..a405847 100644 --- a/packages/validators/src/types/crm.types.ts +++ b/packages/validators/src/types/crm.types.ts @@ -43,3 +43,7 @@ export const DEAL_SORT_BY_VALUES = [ ] as const; export const SORT_ORDER_VALUES = ["asc", "desc"] as const; + +export const CUSTOM_FIELD_ENTITY_TYPE_VALUES = ["people", "org"] as const; + +export const CUSTOM_FIELD_TYPE_VALUES = ["text", "number", "select", "dateTime"] as const;