Prevent duplicate app permission entries from being saved

Original task (#91): The `app_permissions` join table had no unique
constraint, allowing the same `(app_id, permission_id)` pair to be
inserted repeatedly. The Admin Panel restriction had grown to 24
duplicate rows. This change prevents that recurring at the DB level
and verifies that the existing conflict-safe insert path keeps working.

Schema change:
- `lib/db/src/schema/apps.ts`: added a composite primary key on
  `(app_id, permission_id)` for `appPermissionsTable`, matching the
  pattern used by other join tables in this repo (rolePermissions,
  userRoles, groupApps, etc.). This is enforced at the database level.

DB migration:
- Cleaned up the 23 duplicate rows still present in the DB before
  applying the constraint (kept the earliest row per pair using
  `ctid`), then ran `pnpm --filter @workspace/db run push` to sync the
  schema. Verified the new primary key
  `app_permissions_app_id_permission_id_pk` rejects duplicate inserts.

Graceful handling of duplicates:
- The seed script (`scripts/src/seed.ts`) already uses
  `.onConflictDoNothing()` when inserting into `app_permissions`. With
  the new primary key, that call is now properly idempotent (no longer
  silently allowing duplicates).
- There is currently no HTTP route or admin UI that POSTs into
  `app_permissions` (the task description listed `routes/apps.ts` as a
  relevant file, but no such handler exists today). The constraint
  itself is what prevents future regressions, and any future endpoint
  should follow the seed's `.onConflictDoNothing()` pattern.

Tests:
- Added `artifacts/api-server/tests/app-permissions-unique.test.mjs`
  with two cases that prove the new behavior:
  1. A plain duplicate INSERT fails with SQLSTATE 23505 (unique
     violation) and only one row remains.
  2. `INSERT ... ON CONFLICT DO NOTHING` (the pattern Drizzle's
     `.onConflictDoNothing()` emits) handles duplicates gracefully:
     no error thrown, `rowCount` is 0, and exactly one row remains.
- All previously-passing api-server tests for apps/groups still pass
  after the schema change.

Replit-Task-Id: 0589a4dc-5898-4c66-8feb-3cd48289fe89
This commit is contained in:
riyadhafraa
2026-04-28 09:03:59 +00:00
parent 589642e11d
commit bdc19d5012
7 changed files with 649 additions and 84 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, text, serial, timestamp, integer, varchar, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
@@ -22,7 +22,7 @@ export const appsTable = pgTable("apps", {
export const appPermissionsTable = pgTable("app_permissions", {
appId: integer("app_id").notNull().references(() => appsTable.id, { onDelete: "cascade" }),
permissionId: integer("permission_id").notNull(),
});
}, (t) => [primaryKey({ columns: [t.appId, t.permissionId] })]);
export const insertAppSchema = createInsertSchema(appsTable).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertApp = z.infer<typeof insertAppSchema>;