diff --git a/apps/studio/prisma/generated/generatedEnums.ts b/apps/studio/prisma/generated/generatedEnums.ts index 341006ab5..db2053dcf 100644 --- a/apps/studio/prisma/generated/generatedEnums.ts +++ b/apps/studio/prisma/generated/generatedEnums.ts @@ -1,16 +1,16 @@ export const ResourceState = { - Draft: "Draft", - Published: "Published", -} as const -export type ResourceState = (typeof ResourceState)[keyof typeof ResourceState] + Draft: "Draft", + Published: "Published" +} as const; +export type ResourceState = (typeof ResourceState)[keyof typeof ResourceState]; export const ResourceType = { - Page: "Page", - Folder: "Folder", -} as const -export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType] + Page: "Page", + Folder: "Folder" +} as const; +export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType]; export const RoleType = { - Admin: "Admin", - Editor: "Editor", - Publisher: "Publisher", -} as const -export type RoleType = (typeof RoleType)[keyof typeof RoleType] + Admin: "Admin", + Editor: "Editor", + Publisher: "Publisher" +} as const; +export type RoleType = (typeof RoleType)[keyof typeof RoleType]; diff --git a/apps/studio/prisma/generated/generatedTypes.ts b/apps/studio/prisma/generated/generatedTypes.ts index fc0130e34..73a445186 100644 --- a/apps/studio/prisma/generated/generatedTypes.ts +++ b/apps/studio/prisma/generated/generatedTypes.ts @@ -1,90 +1,97 @@ -import type { ColumnType, GeneratedAlways } from "kysely" +import type { ColumnType, GeneratedAlways } from "kysely"; +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; +export type Timestamp = ColumnType; -import type { ResourceState, ResourceType, RoleType } from "./generatedEnums" +import type { ResourceState, ResourceType, RoleType } from "./generatedEnums"; -export type Generated = - T extends ColumnType - ? ColumnType - : ColumnType -export type Timestamp = ColumnType - -export interface Blob { - id: GeneratedAlways - /** - * @kyselyType(PrismaJson.BlobJsonContent) - * [BlobJsonContent] - */ - content: PrismaJson.BlobJsonContent -} -export interface Footer { - id: GeneratedAlways - siteId: number - /** - * @kyselyType(PrismaJson.FooterJsonContent) - * [FooterJsonContent] - */ - content: PrismaJson.FooterJsonContent -} -export interface Navbar { - id: GeneratedAlways - siteId: number - /** - * @kyselyType(PrismaJson.NavbarJsonContent) - * [NavbarJsonContent] - */ - content: PrismaJson.NavbarJsonContent -} -export interface Permission { - id: GeneratedAlways - resourceId: string - userId: string - role: RoleType -} -export interface Resource { - id: GeneratedAlways - title: string - permalink: string - siteId: number - parentId: string | null - mainBlobId: string | null - draftBlobId: string | null - state: Generated - type: ResourceType -} -export interface Site { - id: GeneratedAlways - name: string - /** - * @kyselyType(PrismaJson.SiteJsonConfig) - * [SiteJsonConfig] - */ - config: PrismaJson.SiteJsonConfig -} -export interface SiteMember { - userId: string - siteId: number -} -export interface User { - id: string - name: string - email: string - phone: string - preferredName: string | null -} -export interface VerificationToken { - identifier: string - token: string - attempts: Generated - expires: Timestamp -} -export interface DB { - Blob: Blob - Footer: Footer - Navbar: Navbar - Permission: Permission - Resource: Resource - Site: Site - SiteMember: SiteMember - User: User - VerificationToken: VerificationToken -} +export type Blob = { + id: GeneratedAlways; + /** + * @kyselyType(PrismaJson.BlobJsonContent) + * [BlobJsonContent] + */ + content: PrismaJson.BlobJsonContent; +}; +export type Footer = { + id: GeneratedAlways; + siteId: number; + /** + * @kyselyType(PrismaJson.FooterJsonContent) + * [FooterJsonContent] + */ + content: PrismaJson.FooterJsonContent; +}; +export type Navbar = { + id: GeneratedAlways; + siteId: number; + /** + * @kyselyType(PrismaJson.NavbarJsonContent) + * [NavbarJsonContent] + */ + content: PrismaJson.NavbarJsonContent; +}; +export type Permission = { + id: GeneratedAlways; + resourceId: string; + userId: string; + role: RoleType; +}; +export type Resource = { + id: GeneratedAlways; + title: string; + permalink: string; + siteId: number; + parentId: string | null; + publishedVersionId: string | null; + draftBlobId: string | null; + state: Generated; + type: ResourceType; +}; +export type Site = { + id: GeneratedAlways; + name: string; + /** + * @kyselyType(PrismaJson.SiteJsonConfig) + * [SiteJsonConfig] + */ + config: PrismaJson.SiteJsonConfig; +}; +export type SiteMember = { + userId: string; + siteId: number; +}; +export type User = { + id: string; + name: string; + email: string; + phone: string; + preferredName: string | null; +}; +export type VerificationToken = { + identifier: string; + token: string; + attempts: Generated; + expires: Timestamp; +}; +export type Version = { + id: GeneratedAlways; + versionNum: number; + resourceId: string; + blobId: string; + publishedAt: Generated; + publishedBy: string; +}; +export type DB = { + Blob: Blob; + Footer: Footer; + Navbar: Navbar; + Permission: Permission; + Resource: Resource; + Site: Site; + SiteMember: SiteMember; + User: User; + VerificationToken: VerificationToken; + Version: Version; +}; diff --git a/apps/studio/prisma/generated/selectableTypes.ts b/apps/studio/prisma/generated/selectableTypes.ts index 558f14bb1..1bca01e5c 100644 --- a/apps/studio/prisma/generated/selectableTypes.ts +++ b/apps/studio/prisma/generated/selectableTypes.ts @@ -12,3 +12,4 @@ export type Site = Selectable export type SiteMember = Selectable export type User = Selectable export type VerificationToken = Selectable +export type Version = Selectable diff --git a/apps/studio/prisma/migrations/20240731070336_add_versions/migration.sql b/apps/studio/prisma/migrations/20240731070336_add_versions/migration.sql new file mode 100644 index 000000000..568f48414 --- /dev/null +++ b/apps/studio/prisma/migrations/20240731070336_add_versions/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - You are about to drop the column `mainBlobId` on the `Resource` table. All the data in the column will be lost. + - A unique constraint covering the columns `[publishedVersionId]` on the table `Resource` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "Resource" DROP CONSTRAINT "Resource_mainBlobId_fkey"; + +-- DropIndex +DROP INDEX "Resource_mainBlobId_key"; + +-- AlterTable +ALTER TABLE "Resource" DROP COLUMN "mainBlobId", +ADD COLUMN "publishedVersionId" BIGINT; + +-- CreateTable +CREATE TABLE "Version" ( + "id" BIGSERIAL NOT NULL, + "versionNum" INTEGER NOT NULL, + "resourceId" BIGINT NOT NULL, + "blobId" BIGINT NOT NULL, + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "publishedBy" TEXT NOT NULL, + + CONSTRAINT "Version_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Version_blobId_key" ON "Version"("blobId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Resource_publishedVersionId_key" ON "Resource"("publishedVersionId"); + +-- AddForeignKey +ALTER TABLE "Version" ADD CONSTRAINT "Version_blobId_fkey" FOREIGN KEY ("blobId") REFERENCES "Blob"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Version" ADD CONSTRAINT "Version_publishedBy_fkey" FOREIGN KEY ("publishedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Resource" ADD CONSTRAINT "Resource_publishedVersionId_fkey" FOREIGN KEY ("publishedVersionId") REFERENCES "Version"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/studio/prisma/schema.prisma b/apps/studio/prisma/schema.prisma index 66d0b3e65..95636578d 100644 --- a/apps/studio/prisma/schema.prisma +++ b/apps/studio/prisma/schema.prisma @@ -33,6 +33,18 @@ model VerificationToken { expires DateTime } +model Version { + id BigInt @id @default(autoincrement()) + versionNum Int + resourceId BigInt + resources Resource[] + blobId BigInt @unique + blob Blob @relation(fields: [blobId], references: [id]) + publishedAt DateTime @default(now()) + publishedBy String + publisher User @relation(fields: [publishedBy], references: [id]) +} + model Resource { id BigInt @id @default(autoincrement()) title String @@ -46,16 +58,25 @@ model Resource { permission Permission[] - mainBlob Blob? @relation("MainBlob", fields: [mainBlobId], references: [id]) - mainBlobId BigInt? @unique + publishedVersionId BigInt? @unique + publishedVersion Version? @relation(fields: [publishedVersionId], references: [id]) - draftBlob Blob? @relation("DraftBlob", fields: [draftBlobId], references: [id]) draftBlobId BigInt? @unique + draftBlob Blob? @relation(fields: [draftBlobId], references: [id]) state ResourceState? @default(Draft) type ResourceType } +model Blob { + id BigInt @id @default(autoincrement()) + /// @kyselyType(PrismaJson.BlobJsonContent) + /// [BlobJsonContent] + content Json + draftResource Resource? + version Version? +} + enum ResourceState { Draft Published @@ -73,8 +94,9 @@ model User { phone String preferredName String? - permission Permission[] + permissions Permission[] siteMembers SiteMember[] + versions Version[] } model Permission { @@ -124,16 +146,6 @@ model Footer { content Json } -model Blob { - id BigInt @id @default(autoincrement()) - /// @kyselyType(PrismaJson.BlobJsonContent) - /// [BlobJsonContent] - content Json - - mainResource Resource? @relation("MainBlob") - draftResource Resource? @relation("DraftBlob") -} - model SiteMember { userId String user User @relation(fields: [userId], references: [id]) diff --git a/apps/studio/prisma/seed.ts b/apps/studio/prisma/seed.ts index 66f3f4aeb..997fd3e77 100644 --- a/apps/studio/prisma/seed.ts +++ b/apps/studio/prisma/seed.ts @@ -223,7 +223,7 @@ async function main() { await db .insertInto("Resource") .values({ - mainBlobId: String(blobId), + draftBlobId: String(blobId), permalink: "home", siteId, type: "Page", @@ -232,8 +232,8 @@ async function main() { .onConflict((oc) => oc - .column("mainBlobId") - .doUpdateSet((eb) => ({ mainBlobId: eb.ref("excluded.mainBlobId") })), + .column("draftBlobId") + .doUpdateSet((eb) => ({ draftBlobId: eb.ref("excluded.draftBlobId") })), ) .executeTakeFirstOrThrow() diff --git a/apps/studio/src/server/modules/folder/folder.router.ts b/apps/studio/src/server/modules/folder/folder.router.ts index 31596044a..5a7188fc1 100644 --- a/apps/studio/src/server/modules/folder/folder.router.ts +++ b/apps/studio/src/server/modules/folder/folder.router.ts @@ -26,7 +26,7 @@ export const folderRouter = router({ .where("parentId", "=", String(input.resourceId)) .execute() const children = childrenResult.map((c) => { - if (c.draftBlobId || c.mainBlobId) { + if (c.draftBlobId || c.publishedVersionId) { return { id: c.id, permalink: c.permalink, diff --git a/apps/studio/src/server/modules/page/page.router.ts b/apps/studio/src/server/modules/page/page.router.ts index cf3dec0c2..50d9f8d3e 100644 --- a/apps/studio/src/server/modules/page/page.router.ts +++ b/apps/studio/src/server/modules/page/page.router.ts @@ -78,7 +78,7 @@ export const pageRouter = router({ "Resource.id", "Resource.permalink", "Resource.title", - "Resource.mainBlobId", + "Resource.publishedVersionId", "Resource.draftBlobId", "Resource.type", ]) diff --git a/apps/studio/src/server/modules/resource/resource.service.ts b/apps/studio/src/server/modules/resource/resource.service.ts index f4d616142..b6250fb2b 100644 --- a/apps/studio/src/server/modules/resource/resource.service.ts +++ b/apps/studio/src/server/modules/resource/resource.service.ts @@ -1,4 +1,4 @@ -import type { SelectExpression, Transaction } from "kysely" +import type { SelectExpression } from "kysely" import { type DB } from "~prisma/generated/generatedTypes" import type { SafeKysely } from "../database" @@ -12,7 +12,7 @@ const defaultResourceSelect: SelectExpression[] = [ "Resource.permalink", "Resource.siteId", "Resource.parentId", - "Resource.mainBlobId", + "Resource.publishedVersionId", "Resource.draftBlobId", "Resource.type", "Resource.state", @@ -69,14 +69,14 @@ const getById = ( // NOTE: Throw here to fail early if our invariant that a page has a `blobId` is violated export const getFullPageById = async ( - tx: Transaction, + db: SafeKysely, args: { resourceId: number siteId: number }, ) => { // Check if draft blob exists and return that preferentially - const draftBlob = await getById(tx, args) + const draftBlob = await getById(db, args) .where("Resource.draftBlobId", "is not", null) .innerJoin("Blob", "Resource.draftBlobId", "Blob.id") .select(defaultResourceWithBlobSelect) @@ -88,9 +88,10 @@ export const getFullPageById = async ( return draftBlob } - return getById(tx, args) - .where("Resource.mainBlobId", "is not", null) - .innerJoin("Blob", "Resource.mainBlobId", "Blob.id") + return getById(db, args) + .where("Resource.publishedVersionId", "is not", null) + .innerJoin("Version", "Resource.publishedVersionId", "Version.id") + .innerJoin("Blob", "Version.blobId", "Blob.id") .select(defaultResourceWithBlobSelect) .forUpdate() .executeTakeFirst() diff --git a/apps/studio/src/server/modules/resource/resource.types.ts b/apps/studio/src/server/modules/resource/resource.types.ts index 8e9c7227a..c8d0549f9 100644 --- a/apps/studio/src/server/modules/resource/resource.types.ts +++ b/apps/studio/src/server/modules/resource/resource.types.ts @@ -2,7 +2,6 @@ import { type IsomerPageSchemaType, type IsomerSiteProps, } from "@opengovsg/isomer-components" -import { type SetRequired } from "type-fest" import type { Resource } from "~server/db" @@ -11,8 +10,7 @@ export type PageContent = Omit< "layout" | "LinkComponent" | "ScriptComponent" > -// TODO: Technically mainBlobId is not required before 1st publish -export type Page = SetRequired +export type Page = Resource export interface Navbar { items: IsomerSiteProps["navBarItems"] diff --git a/apps/studio/tests/msw/handlers/page.ts b/apps/studio/tests/msw/handlers/page.ts index 898ecdd6c..6d8c3e123 100644 --- a/apps/studio/tests/msw/handlers/page.ts +++ b/apps/studio/tests/msw/handlers/page.ts @@ -13,23 +13,23 @@ const pageListQuery = (wait?: DelayMode | number) => { id: "4", permalink: "test-page-1", title: "Test page 1", - mainBlobId: "3", - draftBlobId: null, + publishedVersionId: null, + draftBlobId: "3", type: "Page", }, { id: "5", permalink: "test-page-2", title: "Test page 2", - mainBlobId: "4", - draftBlobId: null, + publishedVersionId: null, + draftBlobId: "4", type: "Page", }, { id: "6", permalink: "folder", title: "Test folder 1", - mainBlobId: null, + publishedVersionId: null, draftBlobId: null, type: "Folder", },