Merge pull request #387 from Hestia-Homes/feature/tags-schema

feat(db): portfolio_tag + property_tag tables (tagging system)
This commit is contained in:
KhalimCK 2026-07-13 17:26:13 +01:00 committed by GitHub
commit f801025ce3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 12540 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table";
import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
import * as BulkAddressUploadsSchema from "@/app/db/schema/bulk_address_uploads";
import * as TagsSchema from "@/app/db/schema/tags";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -43,6 +44,7 @@ const schema = {
...UploadedFilesSchema,
...PortfolioOrgSchema,
...BulkAddressUploadsSchema,
...TagsSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,21 @@
CREATE TABLE "portfolio_tag" (
"id" bigserial PRIMARY KEY NOT NULL,
"portfolio_id" bigint NOT NULL,
"name" text NOT NULL,
"colour" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_tag" (
"property_id" bigint NOT NULL,
"tag_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "property_tag_property_id_tag_id_pk" PRIMARY KEY("property_id","tag_id")
);
--> statement-breakpoint
ALTER TABLE "portfolio_tag" ADD CONSTRAINT "portfolio_tag_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_tag" ADD CONSTRAINT "property_tag_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_tag" ADD CONSTRAINT "property_tag_tag_id_portfolio_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."portfolio_tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_portfolio_tag_portfolio_lower_name" ON "portfolio_tag" USING btree ("portfolio_id",lower("name"));--> statement-breakpoint
CREATE INDEX "ix_property_tag_tag" ON "property_tag" USING btree ("tag_id");

File diff suppressed because it is too large Load diff

View file

@ -1891,6 +1891,13 @@
"when": 1783953369297,
"tag": "0270_epc_main_heating_detail_epc_property_index",
"breakpoints": true
},
{
"idx": 271,
"version": "7",
"when": 1783957414552,
"tag": "0271_portfolio_tag_and_property_tag",
"breakpoints": true
}
]
}

87
src/app/db/schema/tags.ts Normal file
View file

@ -0,0 +1,87 @@
import {
bigint,
bigserial,
index,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
import { portfolio } from "./portfolio";
import { property } from "./property";
// A Tag is a user-created, coloured label owned by a Portfolio, used to form an
// arbitrary group of its Properties for bulk actions (CONTEXT.md, ADR-0013).
// Flat (no hierarchy) and many-to-many with Property via `property_tag`.
export const portfolioTag = pgTable(
"portfolio_tag",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// Freeform hex string (e.g. "#7C3AED"); format is validated in the app.
colour: text("colour").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
// Names are unique within a Portfolio, case-insensitively ("Void" vs "void").
uniqueIndex("uq_portfolio_tag_portfolio_lower_name").on(
table.portfolioId,
sql`lower(${table.name})`,
),
],
);
// The many-to-many membership: one row per (Property, Tag). Both FKs cascade —
// deleting a Tag drops its memberships (ADR-0013, hard delete), and deleting a
// Property drops its tags.
export const propertyTag = pgTable(
"property_tag",
{
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id, { onDelete: "cascade" }),
tagId: bigint("tag_id", { mode: "bigint" })
.notNull()
.references(() => portfolioTag.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
// A Property can't hold the same Tag twice.
primaryKey({ columns: [table.propertyId, table.tagId] }),
// The composite PK leads with property_id; reverse lookups ("all Properties
// for this Tag" — chips, filter, run resolution) need a tag_id-leading index.
index("ix_property_tag_tag").on(table.tagId),
],
);
export const portfolioTagRelations = relations(portfolioTag, ({ one, many }) => ({
portfolio: one(portfolio, {
fields: [portfolioTag.portfolioId],
references: [portfolio.id],
}),
memberships: many(propertyTag),
}));
export const propertyTagRelations = relations(propertyTag, ({ one }) => ({
tag: one(portfolioTag, {
fields: [propertyTag.tagId],
references: [portfolioTag.id],
}),
property: one(property, {
fields: [propertyTag.propertyId],
references: [property.id],
}),
}));