Use asset-backed company logos
This commit is contained in:
@@ -14,6 +14,7 @@ function makeCompany(overrides: Partial<Company>): Company {
|
|||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
requireBoardApprovalForNewAgents: false,
|
requireBoardApprovalForNewAgents: false,
|
||||||
brandColor: null,
|
brandColor: null,
|
||||||
|
logoAssetId: null,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ PATCH /api/companies/{companyId}
|
|||||||
{
|
{
|
||||||
"name": "Updated Name",
|
"name": "Updated Name",
|
||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"budgetMonthlyCents": 100000
|
"budgetMonthlyCents": 100000,
|
||||||
|
"logoAssetId": "b9f5e911-6de5-4cd0-8dc6-a55a13bc02f6"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ Valid image content types:
|
|||||||
- `image/gif`
|
- `image/gif`
|
||||||
- `image/svg+xml`
|
- `image/svg+xml`
|
||||||
|
|
||||||
|
Then set the company logo by PATCHing the returned `assetId` into `logoAssetId`.
|
||||||
|
|
||||||
## Archive Company
|
## Archive Company
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -76,7 +79,8 @@ Archives a company. Archived companies are hidden from default listings.
|
|||||||
| `name` | string | Company name |
|
| `name` | string | Company name |
|
||||||
| `description` | string | Company description |
|
| `description` | string | Company description |
|
||||||
| `status` | string | `active`, `paused`, `archived` |
|
| `status` | string | `active`, `paused`, `archived` |
|
||||||
| `logoUrl` | string | Optional path or URL for the logo image |
|
| `logoAssetId` | string | Optional asset id for the stored logo image |
|
||||||
|
| `logoUrl` | string | Optional Paperclip asset content path for the stored logo image |
|
||||||
| `budgetMonthlyCents` | number | Monthly budget limit |
|
| `budgetMonthlyCents` | number | Monthly budget limit |
|
||||||
| `createdAt` | string | ISO timestamp |
|
| `createdAt` | string | ISO timestamp |
|
||||||
| `updatedAt` | string | ISO timestamp |
|
| `updatedAt` | string | ISO timestamp |
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "companies" ADD COLUMN "logo_url" text;
|
|
||||||
12
packages/db/src/migrations/0030_rich_magneto.sql
Normal file
12
packages/db/src/migrations/0030_rich_magneto.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "company_logos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"asset_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_logos" ADD CONSTRAINT "company_logos_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_logos" ADD CONSTRAINT "company_logos_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "company_logos_company_uq" ON "company_logos" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "company_logos_asset_uq" ON "company_logos" USING btree ("asset_id");
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "eb9b85ec-2048-4168-bff1-0e987773342a",
|
"id": "ff007d90-e1a0-4df3-beab-a5be4a47273c",
|
||||||
"prevId": "fdb36f4e-6463-497d-b704-22d33be9b450",
|
"prevId": "fdb36f4e-6463-497d-b704-22d33be9b450",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -2140,12 +2140,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"logo_url": {
|
|
||||||
"name": "logo_url",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp with time zone",
|
"type": "timestamp with time zone",
|
||||||
@@ -2185,6 +2179,110 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
|
"public.company_logos": {
|
||||||
|
"name": "company_logos",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"company_id": {
|
||||||
|
"name": "company_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"asset_id": {
|
||||||
|
"name": "asset_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"company_logos_company_uq": {
|
||||||
|
"name": "company_logos_company_uq",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "company_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"company_logos_asset_uq": {
|
||||||
|
"name": "company_logos_asset_uq",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "asset_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"company_logos_company_id_companies_id_fk": {
|
||||||
|
"name": "company_logos_company_id_companies_id_fk",
|
||||||
|
"tableFrom": "company_logos",
|
||||||
|
"tableTo": "companies",
|
||||||
|
"columnsFrom": [
|
||||||
|
"company_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"company_logos_asset_id_assets_id_fk": {
|
||||||
|
"name": "company_logos_asset_id_assets_id_fk",
|
||||||
|
"tableFrom": "company_logos",
|
||||||
|
"tableTo": "assets",
|
||||||
|
"columnsFrom": [
|
||||||
|
"asset_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
"public.company_memberships": {
|
"public.company_memberships": {
|
||||||
"name": "company_memberships",
|
"name": "company_memberships",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
|
|||||||
@@ -215,8 +215,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 30,
|
"idx": 30,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773668505562,
|
"when": 1773670925214,
|
||||||
"tag": "0030_hot_slipstream",
|
"tag": "0030_rich_magneto",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const companies = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
brandColor: text("brand_color"),
|
brandColor: text("brand_color"),
|
||||||
logoUrl: text("logo_url"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
18
packages/db/src/schema/company_logos.ts
Normal file
18
packages/db/src/schema/company_logos.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { pgTable, uuid, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { assets } from "./assets.js";
|
||||||
|
|
||||||
|
export const companyLogos = pgTable(
|
||||||
|
"company_logos",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||||
|
assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyUq: uniqueIndex("company_logos_company_uq").on(table.companyId),
|
||||||
|
assetUq: uniqueIndex("company_logos_asset_uq").on(table.assetId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { companies } from "./companies.js";
|
export { companies } from "./companies.js";
|
||||||
|
export { companyLogos } from "./company_logos.js";
|
||||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||||
export { agents } from "./agents.js";
|
export { agents } from "./agents.js";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Company {
|
|||||||
spentMonthlyCents: number;
|
spentMonthlyCents: number;
|
||||||
requireBoardApprovalForNewAgents: boolean;
|
requireBoardApprovalForNewAgents: boolean;
|
||||||
brandColor: string | null;
|
brandColor: string | null;
|
||||||
|
logoAssetId: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { COMPANY_STATUSES } from "../constants.js";
|
import { COMPANY_STATUSES } from "../constants.js";
|
||||||
|
|
||||||
const logoUrlSchema = z
|
const logoAssetIdSchema = z.string().uuid().nullable().optional();
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.max(2048)
|
|
||||||
.regex(/^\/api\/assets\/[^\s]+$|^https?:\/\/[^\s]+$/)
|
|
||||||
.nullable()
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const createCompanySchema = z.object({
|
export const createCompanySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||||
logoUrl: logoUrlSchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateCompany = z.infer<typeof createCompanySchema>;
|
export type CreateCompany = z.infer<typeof createCompanySchema>;
|
||||||
@@ -25,7 +18,7 @@ export const updateCompanySchema = createCompanySchema
|
|||||||
spentMonthlyCents: z.number().int().nonnegative().optional(),
|
spentMonthlyCents: z.number().int().nonnegative().optional(),
|
||||||
requireBoardApprovalForNewAgents: z.boolean().optional(),
|
requireBoardApprovalForNewAgents: z.boolean().optional(),
|
||||||
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||||
logoUrl: logoUrlSchema,
|
logoAssetId: logoAssetIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
||||||
|
|||||||
@@ -189,6 +189,31 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
|||||||
expect(createAssetMock).not.toHaveBeenCalled();
|
expect(createAssetMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows supported non-image attachments outside the company logo namespace", async () => {
|
||||||
|
const text = createStorageService("text/plain");
|
||||||
|
const app = createApp(text);
|
||||||
|
|
||||||
|
createAssetMock.mockResolvedValue({
|
||||||
|
...createAsset(),
|
||||||
|
contentType: "text/plain",
|
||||||
|
originalFilename: "note.txt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/companies/company-1/assets/images")
|
||||||
|
.field("namespace", "issues/drafts")
|
||||||
|
.attach("file", Buffer.from("hello"), "note.txt");
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(text.putFile).toHaveBeenCalledWith({
|
||||||
|
companyId: "company-1",
|
||||||
|
namespace: "assets/issues/drafts",
|
||||||
|
originalFilename: "note.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
body: expect.any(Buffer),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||||
const app = createApp(createStorageService("image/svg+xml"));
|
const app = createApp(createStorageService("image/svg+xml"));
|
||||||
createAssetMock.mockResolvedValue(createAsset());
|
createAssetMock.mockResolvedValue(createAsset());
|
||||||
|
|||||||
@@ -116,7 +116,19 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsedMeta.success) {
|
||||||
|
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
||||||
|
const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/");
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = (file.mimetype || "").toLowerCase();
|
||||||
|
if (isCompanyLogoNamespace && !contentType.startsWith("image/")) {
|
||||||
|
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
||||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||||
return;
|
return;
|
||||||
@@ -134,15 +146,6 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
res.status(422).json({ error: "Image is empty" });
|
res.status(422).json({ error: "Image is empty" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
|
||||||
if (!parsedMeta.success) {
|
|
||||||
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
|
||||||
const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/");
|
|
||||||
if (isCompanyLogoNamespace && fileBody.length > MAX_COMPANY_LOGO_BYTES) {
|
if (isCompanyLogoNamespace && fileBody.length > MAX_COMPANY_LOGO_BYTES) {
|
||||||
res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` });
|
res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { eq, count } from "drizzle-orm";
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
companies,
|
companies,
|
||||||
|
companyLogos,
|
||||||
|
assets,
|
||||||
agents,
|
agents,
|
||||||
agentApiKeys,
|
agentApiKeys,
|
||||||
agentRuntimeState,
|
agentRuntimeState,
|
||||||
@@ -23,10 +25,41 @@ import {
|
|||||||
principalPermissionGrants,
|
principalPermissionGrants,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
export function companyService(db: Db) {
|
export function companyService(db: Db) {
|
||||||
const ISSUE_PREFIX_FALLBACK = "CMP";
|
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||||
|
|
||||||
|
const companySelection = {
|
||||||
|
id: companies.id,
|
||||||
|
name: companies.name,
|
||||||
|
description: companies.description,
|
||||||
|
status: companies.status,
|
||||||
|
issuePrefix: companies.issuePrefix,
|
||||||
|
issueCounter: companies.issueCounter,
|
||||||
|
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||||
|
spentMonthlyCents: companies.spentMonthlyCents,
|
||||||
|
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||||
|
brandColor: companies.brandColor,
|
||||||
|
logoAssetId: companyLogos.assetId,
|
||||||
|
createdAt: companies.createdAt,
|
||||||
|
updatedAt: companies.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
||||||
|
return {
|
||||||
|
...company,
|
||||||
|
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompanyQuery(database: Pick<Db, "select">) {
|
||||||
|
return database
|
||||||
|
.select(companySelection)
|
||||||
|
.from(companies)
|
||||||
|
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
||||||
|
}
|
||||||
|
|
||||||
function deriveIssuePrefixBase(name: string) {
|
function deriveIssuePrefixBase(name: string) {
|
||||||
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
||||||
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
||||||
@@ -70,32 +103,97 @@ export function companyService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: () => db.select().from(companies),
|
list: () =>
|
||||||
|
getCompanyQuery(db).then((rows) => rows.map((row) => enrichCompany(row))),
|
||||||
|
|
||||||
getById: (id: string) =>
|
getById: (id: string) =>
|
||||||
db
|
getCompanyQuery(db)
|
||||||
.select()
|
|
||||||
.from(companies)
|
|
||||||
.where(eq(companies.id, id))
|
.where(eq(companies.id, id))
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => (rows[0] ? enrichCompany(rows[0]) : null)),
|
||||||
|
|
||||||
create: async (data: typeof companies.$inferInsert) => createCompanyWithUniquePrefix(data),
|
create: async (data: typeof companies.$inferInsert) => {
|
||||||
|
const created = await createCompanyWithUniquePrefix(data);
|
||||||
|
const row = await getCompanyQuery(db)
|
||||||
|
.where(eq(companies.id, created.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!row) throw notFound("Company not found after creation");
|
||||||
|
return enrichCompany(row);
|
||||||
|
},
|
||||||
|
|
||||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
update: (
|
||||||
db
|
id: string,
|
||||||
.update(companies)
|
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||||
.set({ ...data, updatedAt: new Date() })
|
) =>
|
||||||
.where(eq(companies.id, id))
|
db.transaction(async (tx) => {
|
||||||
.returning()
|
const existing = await getCompanyQuery(tx)
|
||||||
.then((rows) => rows[0] ?? null),
|
.where(eq(companies.id, id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const { logoAssetId, ...companyPatch } = data;
|
||||||
|
|
||||||
|
if (logoAssetId !== undefined && logoAssetId !== null) {
|
||||||
|
const nextLogoAsset = await tx
|
||||||
|
.select({ id: assets.id, companyId: assets.companyId })
|
||||||
|
.from(assets)
|
||||||
|
.where(eq(assets.id, logoAssetId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
||||||
|
if (nextLogoAsset.companyId !== existing.id) {
|
||||||
|
throw unprocessable("Logo asset must belong to the same company");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tx
|
||||||
|
.update(companies)
|
||||||
|
.set({ ...companyPatch, updatedAt: new Date() })
|
||||||
|
.where(eq(companies.id, id))
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!updated) return null;
|
||||||
|
|
||||||
|
if (logoAssetId === null) {
|
||||||
|
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||||
|
} else if (logoAssetId !== undefined) {
|
||||||
|
await tx
|
||||||
|
.insert(companyLogos)
|
||||||
|
.values({
|
||||||
|
companyId: id,
|
||||||
|
assetId: logoAssetId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: companyLogos.companyId,
|
||||||
|
set: {
|
||||||
|
assetId: logoAssetId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
||||||
|
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichCompany({
|
||||||
|
...updated,
|
||||||
|
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
archive: (id: string) =>
|
archive: (id: string) =>
|
||||||
db
|
db.transaction(async (tx) => {
|
||||||
.update(companies)
|
const updated = await tx
|
||||||
.set({ status: "archived", updatedAt: new Date() })
|
.update(companies)
|
||||||
.where(eq(companies.id, id))
|
.set({ status: "archived", updatedAt: new Date() })
|
||||||
.returning()
|
.where(eq(companies.id, id))
|
||||||
.then((rows) => rows[0] ?? null),
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!updated) return null;
|
||||||
|
const row = await getCompanyQuery(tx)
|
||||||
|
.where(eq(companies.id, id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row ? enrichCompany(row) : null;
|
||||||
|
}),
|
||||||
|
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
db.transaction(async (tx) => {
|
db.transaction(async (tx) => {
|
||||||
@@ -116,6 +214,8 @@ export function companyService(db: Db) {
|
|||||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||||
|
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||||
|
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const companiesApi = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
budgetMonthlyCents?: number;
|
budgetMonthlyCents?: number;
|
||||||
logoUrl?: string | null;
|
|
||||||
}) =>
|
}) =>
|
||||||
api.post<Company>("/companies", data),
|
api.post<Company>("/companies", data),
|
||||||
update: (
|
update: (
|
||||||
@@ -26,7 +25,7 @@ export const companiesApi = {
|
|||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
Company,
|
Company,
|
||||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoUrl"
|
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => api.patch<Company>(`/companies/${companyId}`, data),
|
) => api.patch<Company>(`/companies/${companyId}`, data),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ interface CompanyContextValue {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
budgetMonthlyCents?: number;
|
budgetMonthlyCents?: number;
|
||||||
logoUrl?: string | null;
|
|
||||||
}) => Promise<Company>;
|
}) => Promise<Company>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +89,6 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
budgetMonthlyCents?: number;
|
budgetMonthlyCents?: number;
|
||||||
logoUrl?: string | null;
|
|
||||||
}) =>
|
}) =>
|
||||||
companiesApi.create(data),
|
companiesApi.create(data),
|
||||||
onSuccess: (company) => {
|
onSuccess: (company) => {
|
||||||
@@ -104,7 +102,6 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
budgetMonthlyCents?: number;
|
budgetMonthlyCents?: number;
|
||||||
logoUrl?: string | null;
|
|
||||||
}) => {
|
}) => {
|
||||||
return createMutation.mutateAsync(data);
|
return createMutation.mutateAsync(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export function CompanySettings() {
|
|||||||
mutationFn: (file: File) =>
|
mutationFn: (file: File) =>
|
||||||
assetsApi
|
assetsApi
|
||||||
.uploadImage(selectedCompanyId!, file, "companies")
|
.uploadImage(selectedCompanyId!, file, "companies")
|
||||||
.then((asset) => companiesApi.update(selectedCompanyId!, { logoUrl: asset.contentPath })),
|
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
||||||
onSuccess: (company) => {
|
onSuccess: (company) => {
|
||||||
syncLogoState(company.logoUrl);
|
syncLogoState(company.logoUrl);
|
||||||
setLogoUploadError(null);
|
setLogoUploadError(null);
|
||||||
@@ -149,7 +149,7 @@ export function CompanySettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clearLogoMutation = useMutation({
|
const clearLogoMutation = useMutation({
|
||||||
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoUrl: null }),
|
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: null }),
|
||||||
onSuccess: (company) => {
|
onSuccess: (company) => {
|
||||||
setLogoUploadError(null);
|
setLogoUploadError(null);
|
||||||
syncLogoState(company.logoUrl);
|
syncLogoState(company.logoUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user