feat(11-01): add tag-assignment migration and validation scripts (OFFER-13/D-02)

- scripts/migrate-tags.ts: idempotently assigns the "Offerta" tag to every
  services row where migrated_from = 'offer_services' (D-02)
- scripts/validate-tags-migration.ts: row-count + orphan checks for the
  tags consolidation dimension of OFFER-13

NOTE: Not yet executed against the dev/staging DB (178.104.27.55:54321) —
that host is firewalled and only reachable via SSH+docker exec per project
memory, which is out of scope for this agent's authorization. Execution of
this script, scripts/migrate-services.ts, scripts/validate-services-migration.ts,
and scripts/push-11-tags-migration.ts is documented as a pending access gate
in 11-01-SUMMARY.md.
This commit is contained in:
2026-06-13 15:27:07 +02:00
parent 4773487d0c
commit 2f2589f0b9
2 changed files with 105 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq, and } from "drizzle-orm";
async function migrate() {
console.log("Starting tags migration: assigning 'Offerta' tag to services migrated from offer_services...\n");
const offerServices = await db
.select({ id: services.id })
.from(services)
.where(eq(services.migrated_from, "offer_services"));
let taggedCount = 0;
let skippedCount = 0;
for (const service of offerServices) {
const existing = await db
.select()
.from(tags)
.where(
and(
eq(tags.entity_type, "services"),
eq(tags.entity_id, service.id),
eq(tags.name, "Offerta")
)
)
.limit(1);
if (existing.length > 0) {
skippedCount++;
continue;
}
await db.insert(tags).values({
entity_type: "services",
entity_id: service.id,
name: "Offerta",
});
taggedCount++;
}
console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`);
console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next.");
process.exit(0);
}
migrate().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
+55
View File
@@ -0,0 +1,55 @@
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
async function validate() {
let failures = 0;
const [offerServicesCount] = await db
.select({ n: sql<number>`count(*)::int` })
.from(services)
.where(eq(services.migrated_from, "offer_services"));
const [offerTagCount] = await db
.select({ n: sql<number>`count(*)::int` })
.from(tags)
.where(and(eq(tags.entity_type, "services"), eq(tags.name, "Offerta")));
console.log(`offer_services-derived services: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`);
if (offerServicesCount.n !== offerTagCount.n) {
console.log("FAIL: Offerta tag count does not match offer_services-derived services count");
failures++;
} else {
console.log("PASS: all offer_services-derived services have the Offerta tag");
}
const orphanedTags = await db.execute(sql`
SELECT COUNT(*)::int AS n FROM tags t
WHERE t.entity_type = 'services'
AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id)
`);
const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0;
if (orphanedTagsCount > 0) {
console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`);
failures++;
} else {
console.log("PASS: no orphaned tag references");
}
const tagCounts = await db.execute(sql`
SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type
`);
const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>;
for (const row of counts) {
console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`);
}
console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
}
validate().catch((err) => {
console.error("Validation failed:", err);
process.exit(1);
});