From cef96d4b6fe0a4d7d38742565817aca8e6533933 Mon Sep 17 00:00:00 2001 From: Yiming Date: Tue, 7 Mar 2023 14:26:44 +0800 Subject: [PATCH] feat: add prisma passthrough attribute for working around discripancies between zmodel and prisma (#245) --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/react/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- .../validator/datamodel-validator.ts | 33 +- .../src/plugins/prisma/prisma-builder.ts | 70 +- .../src/plugins/prisma/schema-generator.ts | 87 ++- packages/schema/src/res/stdlib.zmodel | 10 + .../tests/generator/prisma-generator.test.ts | 42 +- packages/schema/tests/schema/cal-com.test.ts | 12 + packages/schema/tests/schema/cal-com.zmodel | 665 ++++++++++++++++++ .../schema/tests/schema/sample-todo.test.ts | 4 +- .../validation/datamodel-validation.test.ts | 23 +- packages/sdk/package.json | 2 +- packages/sdk/src/utils.ts | 24 + packages/testtools/package.json | 2 +- packages/testtools/src/schema.ts | 14 +- tests/integration/test-run/package-lock.json | 4 +- .../tests/e2e/todo-presets.test.ts | 3 +- tests/integration/tests/schema/cal-com.zmodel | 665 ++++++++++++++++++ tests/integration/tests/schema/todo.zmodel | 21 + .../tests/with-policy/cal-com-sample.test.ts | 8 + .../tests/with-policy/todo-sample.test.ts | 5 +- 26 files changed, 1618 insertions(+), 90 deletions(-) create mode 100644 packages/schema/tests/schema/cal-com.test.ts create mode 100644 packages/schema/tests/schema/cal-com.zmodel create mode 100644 tests/integration/tests/schema/cal-com.zmodel create mode 100644 tests/integration/tests/with-policy/cal-com-sample.test.ts diff --git a/package.json b/package.json index e36b26de1..788c28e6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 9d96602a0..4f322d2af 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index ee966860d..0a525b995 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 699704058..f3a068f97 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 2c1951a02..935438cd4 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index be3563ecf..2557c8b8b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 991115654..e0053b6c4 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 2e97852ba..0d8b5eeff 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -12,6 +12,7 @@ import { IssueCodes, SCALAR_TYPES } from '../constants'; import { AstValidator } from '../types'; import { getIdFields, getUniqueFields } from '../utils'; import { validateAttributeApplication, validateDuplicatedDeclarations } from './utils'; +import { getLiteral } from '@zenstackhq/sdk'; /** * Validates data model declarations. @@ -174,13 +175,15 @@ export default class DataModelValidator implements AstValidator { const oppositeModelFields = field.type.reference?.ref?.fields as DataModelField[]; if (oppositeModelFields) { for (const oppositeField of oppositeModelFields) { - const { name: oppositeRelationName } = this.parseRelation(oppositeField); - if ( - oppositeRelationName === relationName && - oppositeField.type.reference?.ref === field.$container - ) { - // found an opposite relation field that points back to this field's type - return true; + // find the opposite relation with the matching name + const relAttr = oppositeField.attributes.find((a) => a.decl.ref?.name === '@relation'); + if (relAttr) { + const relNameExpr = relAttr.args.find((a) => !a.name || a.name === 'name'); + const relName = getLiteral(relNameExpr?.value); + if (relName === relationName && oppositeField.type.reference?.ref === field.$container) { + // found an opposite relation field that points back to this field's type + return true; + } } } } @@ -253,13 +256,15 @@ export default class DataModelValidator implements AstValidator { relationOwner = field; } } else { - [field, oppositeField].forEach((f) => - accept( - 'error', - 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', - { node: f } - ) - ); + [field, oppositeField].forEach((f) => { + if (!this.isSelfRelation(f, thisRelation.name)) { + accept( + 'error', + 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', + { node: f } + ); + } + }); return; } diff --git a/packages/schema/src/plugins/prisma/prisma-builder.ts b/packages/schema/src/plugins/prisma/prisma-builder.ts index 60a1d9191..88190334d 100644 --- a/packages/schema/src/plugins/prisma/prisma-builder.ts +++ b/packages/schema/src/plugins/prisma/prisma-builder.ts @@ -80,7 +80,7 @@ export class Generator { } export class DeclarationBase { - public documentations: string[] = []; + constructor(public documentations: string[] = []) {} addComment(name: string): string { this.documentations.push(name); @@ -91,17 +91,29 @@ export class DeclarationBase { return this.documentations.map((x) => `${x}\n`).join(''); } } -export class Model extends DeclarationBase { + +export class ContainerDeclaration extends DeclarationBase { + constructor(documentations: string[] = [], public attributes: (ContainerAttribute | PassThroughAttribute)[] = []) { + super(documentations); + } +} + +export class FieldDeclaration extends DeclarationBase { + constructor(documentations: string[] = [], public attributes: (FieldAttribute | PassThroughAttribute)[] = []) { + super(documentations); + } +} + +export class Model extends ContainerDeclaration { public fields: ModelField[] = []; - public attributes: ModelAttribute[] = []; - constructor(public name: string, public documentations: string[] = []) { - super(); + constructor(public name: string, documentations: string[] = []) { + super(documentations); } addField( name: string, type: ModelFieldType | string, - attributes: FieldAttribute[] = [], + attributes: (FieldAttribute | PassThroughAttribute)[] = [], documentations: string[] = [] ): ModelField { const field = new ModelField(name, type, attributes, documentations); @@ -109,8 +121,8 @@ export class Model extends DeclarationBase { return field; } - addAttribute(name: string, args: AttributeArg[] = []): ModelAttribute { - const attr = new ModelAttribute(name, args); + addAttribute(name: string, args: AttributeArg[] = []) { + const attr = new ContainerAttribute(name, args); this.attributes.push(attr); return attr; } @@ -145,14 +157,14 @@ export class ModelFieldType { } } -export class ModelField extends DeclarationBase { +export class ModelField extends FieldDeclaration { constructor( public name: string, public type: ModelFieldType | string, - public attributes: FieldAttribute[] = [], - public documentations: string[] = [] + attributes: (FieldAttribute | PassThroughAttribute)[] = [], + documentations: string[] = [] ) { - super(); + super(documentations, attributes); } addAttribute(name: string, args: AttributeArg[] = []): FieldAttribute { @@ -178,7 +190,7 @@ export class FieldAttribute { } } -export class ModelAttribute { +export class ContainerAttribute { constructor(public name: string, public args: AttributeArg[] = []) {} toString(): string { @@ -186,6 +198,17 @@ export class ModelAttribute { } } +/** + * Represents @@prisma.passthrough and @prisma.passthrough + */ +export class PassThroughAttribute { + constructor(public text: string) {} + + toString(): string { + return this.text; + } +} + export class AttributeArg { constructor(public name: string | undefined, public value: AttributeArgValue) {} @@ -287,22 +310,25 @@ export class FunctionCallArg { } } -export class Enum extends DeclarationBase { +export class Enum extends ContainerDeclaration { public fields: EnumField[] = []; - public attributes: ModelAttribute[] = []; constructor(public name: string, public documentations: string[] = []) { - super(); + super(documentations); } - addField(name: string, attributes: FieldAttribute[] = [], documentations: string[] = []): EnumField { + addField( + name: string, + attributes: (FieldAttribute | PassThroughAttribute)[] = [], + documentations: string[] = [] + ): EnumField { const field = new EnumField(name, attributes, documentations); this.fields.push(field); return field; } - addAttribute(name: string, args: AttributeArg[] = []): ModelAttribute { - const attr = new ModelAttribute(name, args); + addAttribute(name: string, args: AttributeArg[] = []) { + const attr = new ContainerAttribute(name, args); this.attributes.push(attr); return attr; } @@ -323,7 +349,11 @@ export class Enum extends DeclarationBase { } export class EnumField extends DeclarationBase { - constructor(public name: string, public attributes: FieldAttribute[] = [], public documentations: string[] = []) { + constructor( + public name: string, + public attributes: (FieldAttribute | PassThroughAttribute)[] = [], + public documentations: string[] = [] + ) { super(); } diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index cf1becad0..1820602da 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -1,6 +1,5 @@ import { AstNode, - Attribute, AttributeArg, DataModel, DataModelAttribute, @@ -36,6 +35,8 @@ import { execSync } from '../../utils/exec-utils'; import { AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, + ContainerAttribute as PrismaModelAttribute, + ContainerDeclaration as PrismaContainerDeclaration, DataSourceUrl as PrismaDataSourceUrl, Enum as PrismaEnum, FieldAttribute as PrismaFieldAttribute, @@ -44,12 +45,15 @@ import { FunctionCall as PrismaFunctionCall, FunctionCallArg as PrismaFunctionCallArg, Model as PrismaDataModel, - ModelAttribute as PrismaModelAttribute, ModelFieldType, + PassThroughAttribute as PrismaPassThroughAttribute, PrismaModel, } from './prisma-builder'; import ZModelCodeGenerator from './zmodel-code-generator'; +const MODEL_PASSTHROUGH_ATTR = '@@prisma.passthrough'; +const FIELD_PASSTHROUGH_ATTR = '@prisma.passthrough'; + /** * Generates Prisma schema file */ @@ -173,7 +177,7 @@ export default class PrismaSchemaGenerator { this.generateModelField(model, field); } - // add an "zenstack_guard" field for dealing with pure auth() related conditions + // add an "zenstack_guard" field for dealing with boolean conditions model.addField(GUARD_FIELD_NAME, 'Boolean', [ new PrismaFieldAttribute('@default', [ new PrismaAttributeArg(undefined, new PrismaAttributeArgValue('Boolean', true)), @@ -199,20 +203,29 @@ export default class PrismaSchemaGenerator { ]); } - for (const attr of decl.attributes.filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref))) { - this.generateModelAttribute(model, attr); + for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + this.generateContainerAttribute(model, attr); } decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) .forEach((attr) => model.addComment('/// ' + this.zModelGenerator.generateAttribute(attr))); // user defined comments pass-through decl.comments.forEach((c) => model.addComment(c)); } - private isPrismaAttribute(attr: Attribute) { - return !!attr.attributes.find((a) => a.decl.ref?.name === '@@@prisma'); + private isPrismaAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { + if (!attr.decl.ref) { + return false; + } + const attrDecl = resolved(attr.decl); + return ( + !!attrDecl.attributes.find((a) => a.decl.ref?.name === '@@@prisma') || + // the special pass-through attribute + attrDecl.name === MODEL_PASSTHROUGH_ATTR || + attrDecl.name === FIELD_PASSTHROUGH_ATTR + ); } private generateModelField(model: PrismaDataModel, field: DataModelField) { @@ -224,12 +237,10 @@ export default class PrismaSchemaGenerator { const type = new ModelFieldType(fieldType, field.type.array, field.type.optional); const attributes = field.attributes - .filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => this.isPrismaAttribute(attr)) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter( - (attr) => !attr.decl.ref || !this.isPrismaAttribute(attr.decl.ref) - ); + const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generateAttribute(attr)); @@ -240,10 +251,20 @@ export default class PrismaSchemaGenerator { } private makeFieldAttribute(attr: DataModelFieldAttribute) { - return new PrismaFieldAttribute( - resolved(attr.decl).name, - attr.args.map((arg) => this.makeAttributeArg(arg)) - ); + const attrName = resolved(attr.decl).name; + if (attrName === FIELD_PASSTHROUGH_ATTR) { + const text = getLiteral(attr.args[0].value); + if (text) { + return new PrismaPassThroughAttribute(text); + } else { + throw new PluginError(`Invalid arguments for ${FIELD_PASSTHROUGH_ATTR} attribute`); + } + } else { + return new PrismaFieldAttribute( + attrName, + attr.args.map((arg) => this.makeAttributeArg(arg)) + ); + } } private makeAttributeArg(arg: AttributeArg): PrismaAttributeArg { @@ -295,13 +316,21 @@ export default class PrismaSchemaGenerator { ); } - private generateModelAttribute(model: PrismaDataModel | PrismaEnum, attr: DataModelAttribute) { - model.attributes.push( - new PrismaModelAttribute( - resolved(attr.decl).name, - attr.args.map((arg) => this.makeAttributeArg(arg)) - ) - ); + private generateContainerAttribute(container: PrismaContainerDeclaration, attr: DataModelAttribute) { + const attrName = resolved(attr.decl).name; + if (attrName === MODEL_PASSTHROUGH_ATTR) { + const text = getLiteral(attr.args[0].value); + if (text) { + container.attributes.push(new PrismaPassThroughAttribute(text)); + } + } else { + container.attributes.push( + new PrismaModelAttribute( + attrName, + attr.args.map((arg) => this.makeAttributeArg(arg)) + ) + ); + } } private generateEnum(prisma: PrismaModel, decl: Enum) { @@ -311,12 +340,12 @@ export default class PrismaSchemaGenerator { this.generateEnumField(_enum, field); } - for (const attr of decl.attributes.filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref))) { - this.generateModelAttribute(_enum, attr); + for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + this.generateContainerAttribute(_enum, attr); } decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) .forEach((attr) => _enum.addComment('/// ' + this.zModelGenerator.generateAttribute(attr))); // user defined comments pass-through @@ -325,12 +354,10 @@ export default class PrismaSchemaGenerator { private generateEnumField(_enum: PrismaEnum, field: EnumField) { const attributes = field.attributes - .filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => this.isPrismaAttribute(attr)) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter( - (attr) => !attr.decl.ref || !this.isPrismaAttribute(attr.decl.ref) - ); + const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generateAttribute(attr)); _enum.addField(field.name, attributes, documentations); diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index 07d01c35e..940a348b7 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -329,3 +329,13 @@ attribute @lt(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) * Validates a number field is less than or equal to the given value. */ attribute @lte(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) + +/* + * A utility attribute to allow passthrough of arbitrary attribute text to the generated Prisma schema. + */ +attribute @prisma.passthrough(_ text: String) + +/* + * A utility attribute to allow passthrough of arbitrary attribute text to the generated Prisma schema. + */ +attribute @@prisma.passthrough(_ text: String) diff --git a/packages/schema/tests/generator/prisma-generator.test.ts b/packages/schema/tests/generator/prisma-generator.test.ts index bffcb69c4..a132a7af8 100644 --- a/packages/schema/tests/generator/prisma-generator.test.ts +++ b/packages/schema/tests/generator/prisma-generator.test.ts @@ -1,3 +1,4 @@ +import { getDMMF } from '@prisma/internals'; import fs from 'fs'; import tmp from 'tmp'; import PrismaSchemaGenerator from '../../src/plugins/prisma/schema-generator'; @@ -13,7 +14,7 @@ describe('Prisma generator test', () => { /// This is a comment model Foo { - id String @id + id String @id /// Comment for field value value Int } @@ -27,6 +28,7 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain('/// This is a comment'); expect(content).toContain('/// Comment for field value'); }); @@ -57,6 +59,7 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain(`/// @TypeGraphQL.omit(output: true, input: true)`); expect(content).toContain(`/// @TypeGraphQL.omit(input: ['update', 'where', 'orderBy'])`); expect(content).toContain(`/// @TypeGraphQL.field(name: 'bar')`); @@ -91,10 +94,47 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain(`@@map("_User")`); expect(content).toContain(`@map("_role")`); expect(content).toContain(`@@map("_Role")`); expect(content).toContain(`@map("admin")`); expect(content).toContain(`@map("customer")`); }); + + it('attribute passthrough', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model Foo { + id String @id + name String @prisma.passthrough('@unique()') + x Int + y Int + @@prisma.passthrough('@@index([x, y])') + } + + enum Role { + USER @prisma.passthrough('@map("__user")') + ADMIN @prisma.passthrough('@map("__admin")') + + @@prisma.passthrough('@@map("__role")') + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + provider: '@zenstack/prisma', + schemaPath: 'schema.zmodel', + output: name, + }); + + const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); + expect(content).toContain('@unique()'); + expect(content).toContain('@@index([x, y])'); + }); }); diff --git a/packages/schema/tests/schema/cal-com.test.ts b/packages/schema/tests/schema/cal-com.test.ts new file mode 100644 index 000000000..05da241b9 --- /dev/null +++ b/packages/schema/tests/schema/cal-com.test.ts @@ -0,0 +1,12 @@ +import * as fs from 'fs'; +import path from 'path'; +import { loadModel } from '../utils'; + +describe('Cal.com Schema Tests', () => { + it('model loading', async () => { + const content = fs.readFileSync(path.join(__dirname, './cal-com.zmodel'), { + encoding: 'utf-8', + }); + await loadModel(content); + }); +}); diff --git a/packages/schema/tests/schema/cal-com.zmodel b/packages/schema/tests/schema/cal-com.zmodel new file mode 100644 index 000000000..26dea14a7 --- /dev/null +++ b/packages/schema/tests/schema/cal-com.zmodel @@ -0,0 +1,665 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + output = '../.prisma' + previewFeatures = [] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + +enum SchedulingType { + ROUND_ROBIN @map("roundRobin") + COLLECTIVE @map("collective") +} + +enum PeriodType { + UNLIMITED @map("unlimited") + ROLLING @map("rolling") + RANGE @map("range") +} + +model Host { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) +} + +model EventType { + id Int @id @default(autoincrement()) + /// @zod.min(1) + title String + /// @zod.custom(imports.eventTypeSlug) + slug String + description String? + position Int @default(0) + /// @zod.custom(imports.eventTypeLocations) + locations Json? + length Int + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink? + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + eventName String? + customInputs EventTypeCustomInput[] + /// @zod.custom(imports.eventTypeBookingFields) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) + /// @zod.custom(imports.recurringEventType) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + seatsShowAttendees Boolean? @default(false) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. + price Int @default(0) + // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. + currency String @default("usd") + slotInterval Int? + /// @zod.custom(imports.EventTypeMetaDataSchema) + metadata Json? + /// @zod.custom(imports.successRedirectUrl) + successRedirectUrl String? + workflows WorkflowsOnEventTypes[] + /// @zod.custom(imports.bookingLimitsType) + bookingLimits Json? + @@unique([userId, slug]) + @@unique([teamId, slug]) +} + +model Credential { + id Int @id @default(autoincrement()) + // @@type is deprecated + type String + key Json + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + // How to make it a required column? + appId String? + destinationCalendars DestinationCalendar[] + invalid Boolean? @default(false) +} + +enum IdentityProvider { + CAL + GOOGLE + SAML +} + +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + booking Booking[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? @unique + credentialId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) +} + +enum UserPermissionRole { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + username String? @unique + name String? + /// @zod.email() + email String @unique + emailVerified DateTime? + password String? + bio String? + avatar String? + timeZone String @default("Europe/London") + weekStart String @default("Sunday") + // DEPRECATED - TO BE REMOVED + startTime Int @default(0) + endTime Int @default(1440) + // + bufferTime Int @default(0) + hideBranding Boolean @default(false) + theme String? + createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + // the location where the events will end up + destinationCalendar DestinationCalendar? + away Boolean @default(false) + // participate in dynamic group booking or not + allowDynamicBooking Boolean? @default(true) + /// @zod.custom(imports.userMetadata) + metadata Json? + verified Boolean? @default(false) + role UserPermissionRole @default(USER) + disableImpersonation Boolean @default(false) + impersonatedUsers Impersonations[] @relation("impersonated_user") + impersonatedBy Impersonations[] @relation("impersonated_by_user") + apiKeys ApiKey[] + accounts Account[] + sessions Session[] + Feedback Feedback[] + ownedEventTypes EventType[] @relation("owner") + workflows Workflow[] + routingForms App_RoutingForms_Form[] @relation("routing-form") + verifiedNumbers VerifiedNumber[] + hosts Host[] + @@map(name: "users") +} + +model Team { + id Int @id @default(autoincrement()) + /// @zod.min(1) + name String + /// @zod.min(1) + slug String? @unique + logo String? + bio String? + hideBranding Boolean @default(false) + hideBookATeamMember Boolean @default(false) + members Membership[] + eventTypes EventType[] + workflows Workflow[] + createdAt DateTime @default(now()) + /// @zod.custom(imports.teamMetadataSchema) + metadata Json? + theme String? + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + verifiedNumbers VerifiedNumber[] +} + +enum MembershipRole { + MEMBER + ADMIN + OWNER +} + +model Membership { + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) + @@id([userId, teamId]) +} + +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([identifier, token]) +} + +model BookingReference { + id Int @id @default(autoincrement()) + /// @zod.min(1) + type String + /// @zod.min(1) + uid String + meetingId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + credentialId Int? +} + +model Attendee { + id Int @id @default(autoincrement()) + email String + name String + timeZone String + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? +} + +enum BookingStatus { + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") +} + +model Booking { + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + customInputs Json? + /// @zod.custom(imports.bookingResponses) + responses Json? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + createdAt DateTime @default(now()) + updatedAt DateTime? + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id]) + destinationCalendarId Int? + cancellationReason String? + rejectionReason String? + dynamicEventSlugRef String? + dynamicGroupSlugRef String? + rescheduled Boolean? + fromReschedule String? + recurringEventId String? + smsReminderNumber String? + workflowReminders WorkflowReminder[] + scheduledJobs String[] + /// @zod.custom(imports.bookingMetadataSchema) + metadata Json? +} + +model Schedule { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType[] + name String + timeZone String? + availability Availability[] + @@index([userId]) +} + +model Availability { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime DateTime @db.Time + endTime DateTime @db.Time + date DateTime? @db.Date + Schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model SelectedCalendar { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + @@id([userId, integration, externalId]) +} + +enum EventTypeCustomInputType { + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") + RADIO @map("radio") + PHONE @map("phone") +} + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + label String + type EventTypeCustomInputType + /// @zod.custom(imports.customInputOptionSchema) + options Json? + required Boolean + placeholder String @default("") +} + +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) +} + +model Payment { + id Int @id @default(autoincrement()) + uid String @unique + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + bookingId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + amount Int + fee Int + currency String + success Boolean + refunded Boolean + data Json + externalId String @unique +} + +enum WebhookTriggerEvents { + BOOKING_CREATED + BOOKING_RESCHEDULED + BOOKING_CANCELLED + FORM_SUBMITTED + MEETING_ENDED +} + +model Webhook { + id String @id @unique + userId Int? + eventTypeId Int? + /// @zod.url() + subscriberUrl String + payloadTemplate String? + createdAt DateTime @default(now()) + active Boolean @default(true) + eventTriggers WebhookTriggerEvents[] + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + secret String? + @@unique([userId, subscriberUrl], name: "courseIdentifier") +} + +model Impersonations { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + impersonatedUser User @relation("impersonated_user", fields: [impersonatedUserId], references: [id], onDelete: Cascade) + impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade) + impersonatedUserId Int + impersonatedById Int +} + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? +} + +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int @unique +} + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum AppCategories { + calendar + messaging + other + payment + video + web3 + automation + analytics +} + +model App { + // The slug for the app store public page inside `/apps/[slug]` + slug String @id @unique + // The directory name for `/packages/app-store/[dirName]` + dirName String @unique + // Needed API Keys + keys Json? + // One or multiple categories to which this app belongs + categories AppCategories[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + payments Payment[] + Webhook Webhook[] + ApiKey ApiKey[] + enabled Boolean @default(false) +} + +model App_RoutingForms_Form { + id String @id @default(cuid()) + description String? + routes Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + fields Json? + user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade) + userId Int + responses App_RoutingForms_FormResponse[] + disabled Boolean @default(false) + /// @zod.custom(imports.RoutingFormSettings) + settings Json? +} + +model App_RoutingForms_FormResponse { + id Int @id @default(autoincrement()) + formFillerId String @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + createdAt DateTime @default(now()) + @@unique([formFillerId, formId]) +} + +model Feedback { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating String + comment String? +} + +enum WorkflowTriggerEvents { + BEFORE_EVENT + EVENT_CANCELLED + NEW_EVENT + AFTER_EVENT + RESCHEDULE_EVENT +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + SMS_ATTENDEE + SMS_NUMBER + EMAIL_ADDRESS +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + workflowReminders WorkflowReminder[] + numberRequired Boolean? + sender String? + numberVerificationPending Boolean @default(true) +} + +model Workflow { + id Int @id @default(autoincrement()) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + activeOn WorkflowsOnEventTypes[] + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int +} + +model Deployment { + /// This is a single row table, so we use a fixed id + id Int @id @default(1) + logo String? + /// @zod.custom(imports.DeploymentTheme) + theme Json? + licenseKey String? + agreedLicenseAt DateTime? +} + +enum TimeUnit { + DAY @map("day") + HOUR @map("hour") + MINUTE @map("minute") +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + bookingUid String + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean + workflowStepId Int + workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + cancelled Boolean? +} + +enum WorkflowTemplates { + REMINDER + CUSTOM +} + +enum WorkflowMethods { + EMAIL + SMS +} + +model VerifiedNumber { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + phoneNumber String +} diff --git a/packages/schema/tests/schema/sample-todo.test.ts b/packages/schema/tests/schema/sample-todo.test.ts index 721103891..40387604c 100644 --- a/packages/schema/tests/schema/sample-todo.test.ts +++ b/packages/schema/tests/schema/sample-todo.test.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import path from 'path'; import { loadModel } from '../utils'; -describe('Basic Tests', () => { - it('sample todo schema', async () => { +describe('Sample Todo Schema Tests', () => { + it('model loading', async () => { const content = fs.readFileSync(path.join(__dirname, './todo.zmodel'), { encoding: 'utf-8', }); diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index a0106b86c..6e0c40b1d 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -486,7 +486,7 @@ describe('Data Model Validation Tests', () => { name String? followedBy User[] @relation("UserFollows") following User[] @relation("UserFollows") - } + } `); // many-to-many explicit @@ -499,15 +499,28 @@ describe('Data Model Validation Tests', () => { followedBy Follows[] @relation("following") following Follows[] @relation("follower") } - + model Follows { follower User @relation("follower", fields: [followerId], references: [id]) followerId Int following User @relation("following", fields: [followingId], references: [id]) followingId Int - + @@id([followerId, followingId]) - } + } + `); + + await loadModel(` + ${prelude} + model User { + id Int @id + eventTypes EventType[] @relation("user_eventtype") + } + + model EventType { + id Int @id + users User[] @relation("user_eventtype") + } `); // multiple self relations @@ -522,7 +535,7 @@ describe('Data Model Validation Tests', () => { students User[] @relation("TeacherStudents") followedBy User[] @relation("UserFollows") following User[] @relation("UserFollows") - } + } `); }); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f975377ff..cc50ca25b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 095ba6c46..a1a61a652 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -55,3 +55,27 @@ export function getAttributeArgs(attr: DataModelAttribute | DataModelFieldAttrib } return result; } + +export function getAttributeArg( + attr: DataModelAttribute | DataModelFieldAttribute, + name: string +): Expression | undefined { + for (const arg of attr.args) { + if (arg.$resolvedParam?.name === name) { + return arg.value; + } + } + return undefined; +} + +export function getAttributeArgLiteral( + attr: DataModelAttribute | DataModelFieldAttribute, + name: string +): T | undefined { + for (const arg of attr.args) { + if (arg.$resolvedParam?.name === name) { + return getLiteral(arg.value); + } + } + return undefined; +} diff --git a/packages/testtools/package.json b/packages/testtools/package.json index ddf7c2d13..ca9ffc57a 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index d990c0e2e..25391f862 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -64,12 +64,12 @@ plugin policy { } `; -export async function loadSchemaFromFile(schemaFile: string) { +export async function loadSchemaFromFile(schemaFile: string, addPrelude = true, pushDb = true) { const content = fs.readFileSync(schemaFile, { encoding: 'utf-8' }); - return loadSchema(content); + return loadSchema(content, addPrelude, pushDb); } -export async function loadSchema(schema: string) { +export async function loadSchema(schema: string, addPrelude = true, pushDb = true) { const { name: workDir } = tmp.dirSync(); const root = getWorkspaceRoot(__dirname); @@ -87,10 +87,14 @@ export async function loadSchema(schema: string) { console.log('Workdir:', workDir); process.chdir(workDir); - fs.writeFileSync('schema.zmodel', `${MODEL_PRELUDE}\n${schema}`); + const content = addPrelude ? `${MODEL_PRELUDE}\n${schema}` : schema; + fs.writeFileSync('schema.zmodel', content); run('npm install'); run('npx zenstack generate --no-dependency-check'); - run('npx prisma db push'); + + if (pushDb) { + run('npx prisma db push'); + } const PrismaClient = require(path.join(workDir, '.prisma')).PrismaClient; const prisma = new PrismaClient({ log: ['info', 'warn', 'error'] }); diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 524ee288f..be0159d26 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -156,7 +156,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.58", + "version": "1.0.0-alpha.60", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/tests/integration/tests/e2e/todo-presets.test.ts b/tests/integration/tests/e2e/todo-presets.test.ts index c12238631..26b9afb73 100644 --- a/tests/integration/tests/e2e/todo-presets.test.ts +++ b/tests/integration/tests/e2e/todo-presets.test.ts @@ -9,7 +9,8 @@ describe('Todo Presets Tests', () => { beforeAll(async () => { const { withPresets, prisma: _prisma } = await loadSchemaFromFile( - path.join(__dirname, '../schema/todo.zmodel') + path.join(__dirname, '../schema/todo.zmodel'), + false ); getDb = withPresets; prisma = _prisma; diff --git a/tests/integration/tests/schema/cal-com.zmodel b/tests/integration/tests/schema/cal-com.zmodel new file mode 100644 index 000000000..26dea14a7 --- /dev/null +++ b/tests/integration/tests/schema/cal-com.zmodel @@ -0,0 +1,665 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + output = '../.prisma' + previewFeatures = [] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + +enum SchedulingType { + ROUND_ROBIN @map("roundRobin") + COLLECTIVE @map("collective") +} + +enum PeriodType { + UNLIMITED @map("unlimited") + ROLLING @map("rolling") + RANGE @map("range") +} + +model Host { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) +} + +model EventType { + id Int @id @default(autoincrement()) + /// @zod.min(1) + title String + /// @zod.custom(imports.eventTypeSlug) + slug String + description String? + position Int @default(0) + /// @zod.custom(imports.eventTypeLocations) + locations Json? + length Int + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink? + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + eventName String? + customInputs EventTypeCustomInput[] + /// @zod.custom(imports.eventTypeBookingFields) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) + /// @zod.custom(imports.recurringEventType) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + seatsShowAttendees Boolean? @default(false) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. + price Int @default(0) + // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. + currency String @default("usd") + slotInterval Int? + /// @zod.custom(imports.EventTypeMetaDataSchema) + metadata Json? + /// @zod.custom(imports.successRedirectUrl) + successRedirectUrl String? + workflows WorkflowsOnEventTypes[] + /// @zod.custom(imports.bookingLimitsType) + bookingLimits Json? + @@unique([userId, slug]) + @@unique([teamId, slug]) +} + +model Credential { + id Int @id @default(autoincrement()) + // @@type is deprecated + type String + key Json + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + // How to make it a required column? + appId String? + destinationCalendars DestinationCalendar[] + invalid Boolean? @default(false) +} + +enum IdentityProvider { + CAL + GOOGLE + SAML +} + +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + booking Booking[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? @unique + credentialId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) +} + +enum UserPermissionRole { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + username String? @unique + name String? + /// @zod.email() + email String @unique + emailVerified DateTime? + password String? + bio String? + avatar String? + timeZone String @default("Europe/London") + weekStart String @default("Sunday") + // DEPRECATED - TO BE REMOVED + startTime Int @default(0) + endTime Int @default(1440) + // + bufferTime Int @default(0) + hideBranding Boolean @default(false) + theme String? + createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + // the location where the events will end up + destinationCalendar DestinationCalendar? + away Boolean @default(false) + // participate in dynamic group booking or not + allowDynamicBooking Boolean? @default(true) + /// @zod.custom(imports.userMetadata) + metadata Json? + verified Boolean? @default(false) + role UserPermissionRole @default(USER) + disableImpersonation Boolean @default(false) + impersonatedUsers Impersonations[] @relation("impersonated_user") + impersonatedBy Impersonations[] @relation("impersonated_by_user") + apiKeys ApiKey[] + accounts Account[] + sessions Session[] + Feedback Feedback[] + ownedEventTypes EventType[] @relation("owner") + workflows Workflow[] + routingForms App_RoutingForms_Form[] @relation("routing-form") + verifiedNumbers VerifiedNumber[] + hosts Host[] + @@map(name: "users") +} + +model Team { + id Int @id @default(autoincrement()) + /// @zod.min(1) + name String + /// @zod.min(1) + slug String? @unique + logo String? + bio String? + hideBranding Boolean @default(false) + hideBookATeamMember Boolean @default(false) + members Membership[] + eventTypes EventType[] + workflows Workflow[] + createdAt DateTime @default(now()) + /// @zod.custom(imports.teamMetadataSchema) + metadata Json? + theme String? + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + verifiedNumbers VerifiedNumber[] +} + +enum MembershipRole { + MEMBER + ADMIN + OWNER +} + +model Membership { + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) + @@id([userId, teamId]) +} + +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([identifier, token]) +} + +model BookingReference { + id Int @id @default(autoincrement()) + /// @zod.min(1) + type String + /// @zod.min(1) + uid String + meetingId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + credentialId Int? +} + +model Attendee { + id Int @id @default(autoincrement()) + email String + name String + timeZone String + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? +} + +enum BookingStatus { + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") +} + +model Booking { + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + customInputs Json? + /// @zod.custom(imports.bookingResponses) + responses Json? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + createdAt DateTime @default(now()) + updatedAt DateTime? + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id]) + destinationCalendarId Int? + cancellationReason String? + rejectionReason String? + dynamicEventSlugRef String? + dynamicGroupSlugRef String? + rescheduled Boolean? + fromReschedule String? + recurringEventId String? + smsReminderNumber String? + workflowReminders WorkflowReminder[] + scheduledJobs String[] + /// @zod.custom(imports.bookingMetadataSchema) + metadata Json? +} + +model Schedule { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType[] + name String + timeZone String? + availability Availability[] + @@index([userId]) +} + +model Availability { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime DateTime @db.Time + endTime DateTime @db.Time + date DateTime? @db.Date + Schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model SelectedCalendar { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + @@id([userId, integration, externalId]) +} + +enum EventTypeCustomInputType { + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") + RADIO @map("radio") + PHONE @map("phone") +} + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + label String + type EventTypeCustomInputType + /// @zod.custom(imports.customInputOptionSchema) + options Json? + required Boolean + placeholder String @default("") +} + +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) +} + +model Payment { + id Int @id @default(autoincrement()) + uid String @unique + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + bookingId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + amount Int + fee Int + currency String + success Boolean + refunded Boolean + data Json + externalId String @unique +} + +enum WebhookTriggerEvents { + BOOKING_CREATED + BOOKING_RESCHEDULED + BOOKING_CANCELLED + FORM_SUBMITTED + MEETING_ENDED +} + +model Webhook { + id String @id @unique + userId Int? + eventTypeId Int? + /// @zod.url() + subscriberUrl String + payloadTemplate String? + createdAt DateTime @default(now()) + active Boolean @default(true) + eventTriggers WebhookTriggerEvents[] + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + secret String? + @@unique([userId, subscriberUrl], name: "courseIdentifier") +} + +model Impersonations { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + impersonatedUser User @relation("impersonated_user", fields: [impersonatedUserId], references: [id], onDelete: Cascade) + impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade) + impersonatedUserId Int + impersonatedById Int +} + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? +} + +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int @unique +} + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum AppCategories { + calendar + messaging + other + payment + video + web3 + automation + analytics +} + +model App { + // The slug for the app store public page inside `/apps/[slug]` + slug String @id @unique + // The directory name for `/packages/app-store/[dirName]` + dirName String @unique + // Needed API Keys + keys Json? + // One or multiple categories to which this app belongs + categories AppCategories[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + payments Payment[] + Webhook Webhook[] + ApiKey ApiKey[] + enabled Boolean @default(false) +} + +model App_RoutingForms_Form { + id String @id @default(cuid()) + description String? + routes Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + fields Json? + user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade) + userId Int + responses App_RoutingForms_FormResponse[] + disabled Boolean @default(false) + /// @zod.custom(imports.RoutingFormSettings) + settings Json? +} + +model App_RoutingForms_FormResponse { + id Int @id @default(autoincrement()) + formFillerId String @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + createdAt DateTime @default(now()) + @@unique([formFillerId, formId]) +} + +model Feedback { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating String + comment String? +} + +enum WorkflowTriggerEvents { + BEFORE_EVENT + EVENT_CANCELLED + NEW_EVENT + AFTER_EVENT + RESCHEDULE_EVENT +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + SMS_ATTENDEE + SMS_NUMBER + EMAIL_ADDRESS +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + workflowReminders WorkflowReminder[] + numberRequired Boolean? + sender String? + numberVerificationPending Boolean @default(true) +} + +model Workflow { + id Int @id @default(autoincrement()) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + activeOn WorkflowsOnEventTypes[] + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int +} + +model Deployment { + /// This is a single row table, so we use a fixed id + id Int @id @default(1) + logo String? + /// @zod.custom(imports.DeploymentTheme) + theme Json? + licenseKey String? + agreedLicenseAt DateTime? +} + +enum TimeUnit { + DAY @map("day") + HOUR @map("hour") + MINUTE @map("minute") +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + bookingUid String + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean + workflowStepId Int + workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + cancelled Boolean? +} + +enum WorkflowTemplates { + REMINDER + CUSTOM +} + +enum WorkflowMethods { + EMAIL + SMS +} + +model VerifiedNumber { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + phoneNumber String +} diff --git a/tests/integration/tests/schema/todo.zmodel b/tests/integration/tests/schema/todo.zmodel index d56717eb8..313cb04ac 100644 --- a/tests/integration/tests/schema/todo.zmodel +++ b/tests/integration/tests/schema/todo.zmodel @@ -2,6 +2,27 @@ * Sample model for a collaborative Todo app */ +datasource db { + provider = 'sqlite' + url = 'file:./test.db' +} + +generator js { + provider = 'prisma-client-js' + output = '../.prisma' + previewFeatures = ['clientExtensions'] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + /* * Model for a space in which users can collaborate on Lists and Todos */ diff --git a/tests/integration/tests/with-policy/cal-com-sample.test.ts b/tests/integration/tests/with-policy/cal-com-sample.test.ts new file mode 100644 index 000000000..2aa94e4c9 --- /dev/null +++ b/tests/integration/tests/with-policy/cal-com-sample.test.ts @@ -0,0 +1,8 @@ +import { loadSchemaFromFile } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Cal.com Sample Integration Tests', () => { + it('model loading', async () => { + await loadSchemaFromFile(path.join(__dirname, '../schema/cal-com.zmodel'), false, false); + }); +}); diff --git a/tests/integration/tests/with-policy/todo-sample.test.ts b/tests/integration/tests/with-policy/todo-sample.test.ts index 6c34f1079..4b61e20a8 100644 --- a/tests/integration/tests/with-policy/todo-sample.test.ts +++ b/tests/integration/tests/with-policy/todo-sample.test.ts @@ -7,7 +7,10 @@ describe('Todo Policy Tests', () => { let prisma: WeakDbClientContract; beforeAll(async () => { - const { withPolicy, prisma: _prisma } = await loadSchemaFromFile(path.join(__dirname, '../schema/todo.zmodel')); + const { withPolicy, prisma: _prisma } = await loadSchemaFromFile( + path.join(__dirname, '../schema/todo.zmodel'), + false + ); getDb = withPolicy; prisma = _prisma; });