diff --git a/@api/database/migrations/0001_grey_wiccan.sql b/@api/database/migrations/0001_grey_wiccan.sql new file mode 100644 index 0000000..9a191d3 --- /dev/null +++ b/@api/database/migrations/0001_grey_wiccan.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "organizations_invitations" ( + "id" char(64) PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "role" "organization_member_roles" DEFAULT 'member' NOT NULL, + "expired_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organizations_invitations" ADD CONSTRAINT "organizations_invitations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/@api/database/migrations/0002_medical_lilith.sql b/@api/database/migrations/0002_medical_lilith.sql new file mode 100644 index 0000000..785778e --- /dev/null +++ b/@api/database/migrations/0002_medical_lilith.sql @@ -0,0 +1 @@ +ALTER TABLE "organizations_invitations" ADD CONSTRAINT "organizations_invitations_organization_id_email_unique" UNIQUE("organization_id","email"); \ No newline at end of file diff --git a/@api/database/migrations/meta/0001_snapshot.json b/@api/database/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..1ea84eb --- /dev/null +++ b/@api/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,380 @@ +{ + "id": "87cbe321-13e9-4e5f-88f6-a57d14cbd28c", + "prevId": "be57e4a8-c363-4a94-93e7-dba50163ab8b", + "version": "5", + "dialect": "pg", + "tables": { + "email_otps": { + "name": "email_otps", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "oauth_account_providers", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_provider_user_id_pk": { + "name": "oauth_accounts_provider_provider_user_id_pk", + "columns": ["provider", "provider_user_id"] + } + }, + "uniqueConstraints": { + "oauth_accounts_provider_user_id_unique": { + "name": "oauth_accounts_provider_user_id_unique", + "nullsNotDistinct": false, + "columns": ["provider", "user_id"] + } + } + }, + "organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_members_user_id_organization_id_pk": { + "name": "organization_members_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {} + }, + "organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "organizations_invitations": { + "name": "organizations_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organizations_invitations_organization_id_organizations_id_fk": { + "name": "organizations_invitations_organization_id_organizations_id_fk", + "tableFrom": "organizations_invitations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_organization_id_fkey": { + "name": "sessions_user_id_organization_id_fkey", + "tableFrom": "sessions", + "tableTo": "organization_members", + "columnsFrom": ["user_id", "organization_id"], + "columnsTo": ["user_id", "organization_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + } + }, + "enums": { + "oauth_account_providers": { + "name": "oauth_account_providers", + "values": { + "github": "github", + "google": "google" + } + }, + "organization_member_roles": { + "name": "organization_member_roles", + "values": { + "admin": "admin", + "member": "member" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/@api/database/migrations/meta/0002_snapshot.json b/@api/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..804888d --- /dev/null +++ b/@api/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,386 @@ +{ + "id": "4fc9b2bd-a02a-4557-9edb-189a297fc0df", + "prevId": "87cbe321-13e9-4e5f-88f6-a57d14cbd28c", + "version": "5", + "dialect": "pg", + "tables": { + "email_otps": { + "name": "email_otps", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "oauth_account_providers", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_provider_user_id_pk": { + "name": "oauth_accounts_provider_provider_user_id_pk", + "columns": ["provider", "provider_user_id"] + } + }, + "uniqueConstraints": { + "oauth_accounts_provider_user_id_unique": { + "name": "oauth_accounts_provider_user_id_unique", + "nullsNotDistinct": false, + "columns": ["provider", "user_id"] + } + } + }, + "organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_members_user_id_organization_id_pk": { + "name": "organization_members_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {} + }, + "organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "organizations_invitations": { + "name": "organizations_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organizations_invitations_organization_id_organizations_id_fk": { + "name": "organizations_invitations_organization_id_organizations_id_fk", + "tableFrom": "organizations_invitations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_invitations_organization_id_email_unique": { + "name": "organizations_invitations_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": ["organization_id", "email"] + } + } + }, + "sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_organization_id_fkey": { + "name": "sessions_user_id_organization_id_fkey", + "tableFrom": "sessions", + "tableTo": "organization_members", + "columnsFrom": ["user_id", "organization_id"], + "columnsTo": ["user_id", "organization_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + } + }, + "enums": { + "oauth_account_providers": { + "name": "oauth_account_providers", + "values": { + "github": "github", + "google": "google" + } + }, + "organization_member_roles": { + "name": "organization_member_roles", + "values": { + "admin": "admin", + "member": "member" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/@api/database/migrations/meta/_journal.json b/@api/database/migrations/meta/_journal.json index 5ad3aed..24c4f7d 100644 --- a/@api/database/migrations/meta/_journal.json +++ b/@api/database/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1702472647210, "tag": "0000_simple_nextwave", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1702714658330, + "tag": "0001_grey_wiccan", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1702870623178, + "tag": "0002_medical_lilith", + "breakpoints": true } ] } diff --git a/@api/database/schema.ts b/@api/database/schema.ts index 2c95528..dc1473e 100644 --- a/@api/database/schema.ts +++ b/@api/database/schema.ts @@ -58,9 +58,7 @@ export const OauthAccountRelations = relations(OauthAccounts, ({ one, many }) => export const EmailOtps = pgTable('email_otps', { email: varchar('email', { length: 255 }).notNull().primaryKey(), code: varchar('code', { length: 6 }).notNull(), - expiresAt: timestamp('expired_at') - .notNull() - .$default(() => new Date(Date.now() + 1000 * 60 * 5)), + expiresAt: timestamp('expired_at').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }) @@ -75,6 +73,7 @@ export const Organizations = pgTable('organizations', { export const OrganizationRelations = relations(Organizations, ({ many }) => ({ members: many(OrganizationMembers), + invitations: many(OrganizationsInvitations), })) export const organizationMembersRoles = pgEnum('organization_member_roles', ['admin', 'member']) @@ -139,3 +138,30 @@ export const SessionRelations = relations(Sessions, ({ one }) => ({ references: [OrganizationMembers.userId, OrganizationMembers.organizationId], }), })) + +export const OrganizationsInvitations = pgTable( + 'organizations_invitations', + { + id: char('id', { length: 64 }) + .notNull() + .primaryKey() + .$defaultFn(() => generateRandomString(64, alphabet('a-z', 'A-Z', '0-9'))), + organizationId: uuid('organization_id') + .notNull() + .references(() => Organizations.id), + email: varchar('email', { length: 255 }).notNull(), + role: organizationMembersRoles('role').notNull().default('member'), + expiresAt: timestamp('expired_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + pu: unique().on(t.organizationId, t.email), + }), +) + +export const OrganizationsInvitationRelations = relations(OrganizationsInvitations, ({ one }) => ({ + organization: one(Organizations, { + fields: [OrganizationsInvitations.organizationId], + references: [Organizations.id], + }), +})) diff --git a/@api/emails/organization-invitation/index.ts b/@api/emails/organization-invitation/index.ts new file mode 100644 index 0000000..aad8d37 --- /dev/null +++ b/@api/emails/organization-invitation/index.ts @@ -0,0 +1,18 @@ +import template from './template.html' + +export function generateOrganizationInvitationEmail(data: { + inviterName: string + organizationName: string + invitationAcceptUrl: string +}) { + const html = template + .replaceAll('{{INVITER_NAME}}', data.inviterName) + .replaceAll('{{ORGANIZATION_NAME}}', data.organizationName) + .replaceAll('{{INVITATION_ACCEPT_URL}}', data.invitationAcceptUrl) + const subject = 'You have been invited to join an organization' + + return { + html, + subject, + } +} diff --git a/@api/emails/organization-invitation/template.html b/@api/emails/organization-invitation/template.html new file mode 100644 index 0000000..dc0a323 --- /dev/null +++ b/@api/emails/organization-invitation/template.html @@ -0,0 +1,99 @@ + + +
+ + + + +
+ + {{INVITER_NAME}} invited you to join + "{{ORGANIZATION_NAME}}" + on ResolveX.ai ++
+
+ Join now
+
+
+ |
+
+
+ + ResolveX.ai, forge vital customer connections + + |
+