feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
role: text("role"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
isAnonymous: boolean("is_anonymous"),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
+57
-6
@@ -1,24 +1,75 @@
|
||||
import * as CONSTANTS from './constants';
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { admin, anonymous } from 'better-auth/plugins';
|
||||
import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email-verification';
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { createProfileHook } from './auth/hooks/create-profile';
|
||||
import { db } from '@db/drizzle';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { config as emailAndPassword } from './auth/email-password';
|
||||
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: CONSTANTS.BASE_URL,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema
|
||||
schema,
|
||||
debugLogs: true
|
||||
}),
|
||||
emailVerification: {},
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
emailAndPassword: emailAndPassword,
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailConfirmation
|
||||
}
|
||||
},
|
||||
emailVerification: {
|
||||
enabled: true,
|
||||
sendVerificationEmail
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
accessType: 'offline',
|
||||
prompt: 'select_account consent'
|
||||
}
|
||||
},
|
||||
trustedOrigins: CONSTANTS.TRUSTED_ORIGINS,
|
||||
advanced: {
|
||||
cookiePrefix: 'classroomio',
|
||||
crossSubDomainCookies: {
|
||||
enabled: true
|
||||
},
|
||||
database: {
|
||||
generateId: false
|
||||
}
|
||||
},
|
||||
account: {
|
||||
storeAccountCookie: true
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 60 // 1 hour
|
||||
}
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
await createProfileHook(user);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
after: async (user) => {
|
||||
await syncUserWithProfile(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [admin(), anonymous()]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as schema from '@db/schema';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
import { db, eq } from '@db/drizzle';
|
||||
import { type BetterAuthOptions } from 'better-auth';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
/**
|
||||
* Configuration for the email and password options
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password
|
||||
*/
|
||||
export const config: BetterAuthOptions['emailAndPassword'] = {
|
||||
enabled: true,
|
||||
password: {
|
||||
hash: async (password) => {
|
||||
return await bcrypt.hash(password, 10);
|
||||
},
|
||||
verify: async ({ hash, password }) => {
|
||||
return await bcrypt.compare(password, hash);
|
||||
}
|
||||
},
|
||||
sendResetPassword: sendResetPassword,
|
||||
onPasswordReset: onPasswordReset
|
||||
};
|
||||
|
||||
/**
|
||||
* Types for the email and password options
|
||||
*/
|
||||
type ResetPasswordOptions = Parameters<
|
||||
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['sendResetPassword']>
|
||||
>[0];
|
||||
type OnPasswordResetEmail = Parameters<
|
||||
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['onPasswordReset']>
|
||||
>[0];
|
||||
|
||||
async function sendResetPassword(options: ResetPasswordOptions) {
|
||||
const { user, url } = options;
|
||||
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
await sendEmail('forgotPassword', {
|
||||
to: user.email,
|
||||
fields: {
|
||||
name: profile.fullname,
|
||||
email: user.email,
|
||||
link: url
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onPasswordReset(options: OnPasswordResetEmail) {
|
||||
const { user } = options;
|
||||
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
await sendEmail('onPasswordReset', {
|
||||
to: user.email,
|
||||
fields: {
|
||||
name: profile.fullname
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type BetterAuthOptions } from 'better-auth';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
type EmailVerificationOptions = Parameters<
|
||||
NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']>
|
||||
>[0];
|
||||
|
||||
type ChangeEmailConfirmationOptions = Parameters<
|
||||
NonNullable<NonNullable<NonNullable<BetterAuthOptions['user']>['changeEmail']>['sendChangeEmailConfirmation']>
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* Email verification flow.
|
||||
*
|
||||
* 1. user says I want to change my email from email@old.com to email@new.com
|
||||
* 2. we send an email to email@old.com asking user to verify if they really want to change their email to email@new.com `sendChangeEmailConfirmation()`. [better-auth].
|
||||
* 3. user clicks the link in the email
|
||||
* 4. we update the email in the database. [better-auth].
|
||||
* 5. we send a confirmation email to email@new.com `sendVerificationEmail()`. [dashboard].
|
||||
* 6. user clicks the link in the email
|
||||
* 7. we mark email@new.com as verified. [better-auth].
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sends a verification email to the user when they sign up or change their email address.
|
||||
* @param options - The options for sending the verification email.
|
||||
* @returns The verification email.
|
||||
*/
|
||||
export const sendVerificationEmail = async (options: EmailVerificationOptions) => {
|
||||
const { user, url } = options;
|
||||
console.log('\nsendVerificationEmail');
|
||||
|
||||
// The purpose of this is to prevent verification email trigger from better-auth because we already have a flow in the UI for triggering this email.
|
||||
if (!url.includes('trigger=app') && !url.includes('trigger%3Dapp')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail('verifyEmail', {
|
||||
to: user.email,
|
||||
fields: {
|
||||
link: url,
|
||||
userName: user.name || undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a confirmation email to the user when they change their email address.
|
||||
* @param options - The options for sending the change email confirmation.
|
||||
* @returns The confirmation email.
|
||||
*/
|
||||
export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmationOptions) => {
|
||||
const { user, newEmail, url } = options;
|
||||
console.log('\nsendChangeEmailConfirmation');
|
||||
|
||||
await sendEmail('verifyEmail', {
|
||||
to: user.email,
|
||||
fields: {
|
||||
link: url,
|
||||
newEmail,
|
||||
userName: user.name || undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* This hook runs after user creation (email/password or OAuth signup).
|
||||
*
|
||||
* We use the profile table for everything related to the user because we heavily used supabase and supabase didn't allow you add fields to the auth.users table.
|
||||
*
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.debug('createProfileHook', user);
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
if (existingProfile.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const googleAccount = await db
|
||||
.select()
|
||||
.from(schema.account)
|
||||
.where(and(eq(schema.account.userId, user.id), eq(schema.account.providerId, 'google')))
|
||||
.limit(1);
|
||||
|
||||
const isEmailVerified = user.emailVerified || googleAccount.length > 0;
|
||||
const verifiedAt = isEmailVerified ? new Date().toISOString() : undefined;
|
||||
|
||||
// Extract username from email (part before @)
|
||||
const emailMatch = user.email?.match(/^([^@]+)@/);
|
||||
const emailPrefix = emailMatch ? emailMatch[1] : 'user';
|
||||
const username = `${emailPrefix}${new Date().getTime()}`;
|
||||
const fullname = emailPrefix;
|
||||
|
||||
try {
|
||||
const newProfile = await db.insert(schema.profile).values({
|
||||
id: user.id,
|
||||
username,
|
||||
fullname,
|
||||
email: user.email || undefined,
|
||||
isEmailVerified,
|
||||
verifiedAt
|
||||
});
|
||||
console.debug('newProfile', newProfile);
|
||||
} catch (error) {
|
||||
console.error('Error creating profile for user:', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createAuthMiddleware } from 'better-auth/api';
|
||||
|
||||
export type AuthMiddlewareContext = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0];
|
||||
|
||||
/**
|
||||
* Array of all hooks to run after better-auth requests
|
||||
*
|
||||
* Add new hooks here to register them
|
||||
*/
|
||||
const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> = [
|
||||
// add hooks here
|
||||
];
|
||||
|
||||
/**
|
||||
* Combines all after hooks into a single function
|
||||
* This function will be called by better-auth after each request
|
||||
*/
|
||||
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
||||
for (const hook of afterHooks) {
|
||||
try {
|
||||
await hook(context);
|
||||
} catch (error) {
|
||||
console.error('Error executing hook:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const syncUserWithProfile = async (user: User) => {
|
||||
console.debug('syncUserWithProfile', user);
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// update profile and mark as verified
|
||||
await db
|
||||
.update(schema.profile)
|
||||
.set({
|
||||
email: user.email,
|
||||
isEmailVerified: user.emailVerified
|
||||
})
|
||||
.where(eq(schema.profile.id, user.id));
|
||||
} catch (error) {
|
||||
console.error('Error marking profile as verified:', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, SERVER_URL } = process.env;
|
||||
|
||||
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
|
||||
? TRUSTED_ORIGINS_STRING.split(',')
|
||||
: ['http://localhost:5173', 'http://*.classroomio.*'];
|
||||
|
||||
export const BASE_URL = SERVER_URL || 'http://localhost:3002';
|
||||
@@ -1,16 +1,14 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import * as schema from './schema';
|
||||
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
|
||||
// import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
||||
|
||||
export const db = drizzle(connectionString, {
|
||||
schema
|
||||
});
|
||||
export const db = drizzle(connectionString);
|
||||
|
||||
export * from 'drizzle-orm';
|
||||
export * from './schema';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Main entry point for @cio/db package
|
||||
export * from './drizzle';
|
||||
export * from './schema';
|
||||
export * from './relations';
|
||||
export * from './auth';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "verification" (
|
||||
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
ALTER TABLE "account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
ALTER TABLE "session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1760912065001,
|
||||
"tag": "0001_tranquil_thunderbolts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1762733677656,
|
||||
"tag": "0002_many_revanche",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { customSession } from 'better-auth/plugins';
|
||||
import { getProfileById } from './profile';
|
||||
import { getOrganizationByProfileId } from '../organization';
|
||||
|
||||
export const getCustomSession = () =>
|
||||
customSession(async ({ user, session }) => {
|
||||
const profile = await getProfileById(user.id);
|
||||
const organizations = await getOrganizationByProfileId(user.id);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt
|
||||
},
|
||||
profile,
|
||||
session,
|
||||
organizations
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth';
|
||||
export * from './profile';
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TProfile } from '@db/types';
|
||||
import { db } from '@db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const getProfileById = async (id: string) => {
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
|
||||
|
||||
return updatedProfile;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* Gets courses by organization siteName
|
||||
* @param siteName Organization site name
|
||||
* @returns Array of courses with lesson counts and organization info
|
||||
*/
|
||||
export const getCoursesBySiteName = async (siteName: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName,
|
||||
avatarUrl: schema.organization.avatarUrl
|
||||
},
|
||||
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organization.siteName, siteName),
|
||||
eq(schema.course.status, 'ACTIVE'),
|
||||
eq(schema.course.isPublished, true)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
schema.course.id,
|
||||
schema.organization.id,
|
||||
schema.organization.name,
|
||||
schema.organization.siteName,
|
||||
schema.organization.avatarUrl
|
||||
);
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.course,
|
||||
lessons: [{ count: Number(row.lessonCount) }],
|
||||
group: {
|
||||
organization: row.organization
|
||||
}
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './organization';
|
||||
export * from './auth';
|
||||
export * from './course';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './organization';
|
||||
@@ -0,0 +1,307 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { OrganizationPlan, OrganizationWithMemberAndPlans, OrganizationWithPlans } from './types';
|
||||
import type {
|
||||
TNewOrganization,
|
||||
TNewOrganizationPlan,
|
||||
TNewOrganizationmember,
|
||||
TOrganization,
|
||||
TOrganizationPlan
|
||||
} from '@db/types';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
|
||||
const result = await db
|
||||
.select({
|
||||
organization: schema.organization,
|
||||
roleId: schema.organizationmember.roleId,
|
||||
plan: {
|
||||
planName: schema.organizationPlan.planName,
|
||||
isActive: schema.organizationPlan.isActive,
|
||||
provider: schema.organizationPlan.provider,
|
||||
subscriptionId: schema.organizationPlan.subscriptionId,
|
||||
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
|
||||
}
|
||||
})
|
||||
.from(schema.organization)
|
||||
.leftJoin(schema.organizationmember, eq(schema.organization.id, schema.organizationmember.organizationId))
|
||||
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
|
||||
.where(eq(schema.organizationmember.profileId, profileId));
|
||||
|
||||
// Group by organization and collect plans into an array
|
||||
const organizationMap = new Map<
|
||||
string,
|
||||
{
|
||||
organization: typeof schema.organization.$inferSelect;
|
||||
roleId: number | undefined;
|
||||
plans: Array<OrganizationPlan>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of result) {
|
||||
const orgId = row.organization.id;
|
||||
|
||||
if (!organizationMap.has(orgId)) {
|
||||
organizationMap.set(orgId, {
|
||||
organization: row.organization,
|
||||
roleId: row.roleId ?? undefined,
|
||||
plans: []
|
||||
});
|
||||
}
|
||||
|
||||
const orgData = organizationMap.get(orgId)!;
|
||||
|
||||
// Add plan to array if it exists (not null)
|
||||
if (
|
||||
row.plan &&
|
||||
(row.plan.planName !== null ||
|
||||
row.plan.isActive !== null ||
|
||||
row.plan.provider !== null ||
|
||||
row.plan.subscriptionId !== null)
|
||||
) {
|
||||
orgData.plans.push({
|
||||
planName: row.plan.planName,
|
||||
isActive: row.plan.isActive,
|
||||
provider: row.plan.provider,
|
||||
subscriptionId: row.plan.subscriptionId,
|
||||
customerId: row.plan.customerId as string | null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(organizationMap.values()).map(({ organization, ...rest }) => ({
|
||||
...(organization as TOrganization),
|
||||
...rest
|
||||
}));
|
||||
};
|
||||
|
||||
export const createOrganization = async (data: TNewOrganization) => {
|
||||
const [organization] = await db.insert(schema.organization).values(data).returning();
|
||||
|
||||
return organization;
|
||||
};
|
||||
|
||||
export const createOrganizationMember = async (data: TNewOrganizationmember) => {
|
||||
const [member] = await db.insert(schema.organizationmember).values(data).returning();
|
||||
|
||||
return member;
|
||||
};
|
||||
|
||||
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
|
||||
const result = await db
|
||||
.select({ id: schema.organization.id })
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.siteName, siteName))
|
||||
.limit(1);
|
||||
|
||||
console.debug('checkSiteNameExists result:', result);
|
||||
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
export const deleteOrganizationById = async (id: string) => {
|
||||
await db.delete(schema.organization).where(eq(schema.organization.id, id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization team members (non-students)
|
||||
* @param orgId Organization ID
|
||||
* @returns Array of team members with profile information
|
||||
*/
|
||||
export const getOrganizationTeam = async (orgId: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.organizationmember.id,
|
||||
email: schema.organizationmember.email,
|
||||
verified: schema.organizationmember.verified,
|
||||
roleId: schema.organizationmember.roleId,
|
||||
profile: {
|
||||
id: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
email: schema.profile.email
|
||||
}
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
sql`${schema.organizationmember.roleId} != ${ROLE.STUDENT}`
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${schema.organizationmember.id} DESC`);
|
||||
|
||||
return result.map((member) => ({
|
||||
id: member.id,
|
||||
email: member.profile?.email || member.email || '',
|
||||
verified: member.verified,
|
||||
roleId: member.roleId,
|
||||
profileId: member.profile?.id,
|
||||
fullname: member.profile?.fullname || ''
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization audience (students who are participants in any course)
|
||||
* @param orgId Organization ID
|
||||
* @returns Array of student profiles
|
||||
*/
|
||||
export const getOrganizationAudience = async (orgId: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
email: schema.profile.email,
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
createdAt: schema.profile.createdAt
|
||||
})
|
||||
.from(schema.profile)
|
||||
.innerJoin(schema.groupmember, eq(schema.profile.id, schema.groupmember.profileId))
|
||||
.innerJoin(schema.group, eq(schema.groupmember.groupId, schema.group.id))
|
||||
.where(and(eq(schema.group.organizationId, orgId), eq(schema.groupmember.roleId, ROLE.STUDENT)));
|
||||
|
||||
return result.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.fullname,
|
||||
email: profile.email || '',
|
||||
avatar_url: profile.avatarUrl || '',
|
||||
date_joined: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organizations with optional filters
|
||||
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
|
||||
* @returns Array of organizations with plans
|
||||
*/
|
||||
export const getOrganizations = async (filters?: {
|
||||
siteName?: string;
|
||||
customDomain?: string;
|
||||
isCustomDomainVerified?: boolean;
|
||||
}): Promise<OrganizationWithPlans[]> => {
|
||||
const conditions: Parameters<typeof and>[0][] = [];
|
||||
|
||||
if (filters?.siteName) {
|
||||
conditions.push(eq(schema.organization.siteName, filters.siteName));
|
||||
}
|
||||
|
||||
if (filters?.customDomain) {
|
||||
const customDomainConditions: Parameters<typeof and>[0][] = [
|
||||
eq(schema.organization.customDomain, filters.customDomain)
|
||||
];
|
||||
if (filters.isCustomDomainVerified !== undefined) {
|
||||
customDomainConditions.push(eq(schema.organization.isCustomDomainVerified, filters.isCustomDomainVerified));
|
||||
}
|
||||
conditions.push(and(...customDomainConditions));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
organization: schema.organization,
|
||||
plan: {
|
||||
planName: schema.organizationPlan.planName,
|
||||
isActive: schema.organizationPlan.isActive,
|
||||
provider: schema.organizationPlan.provider,
|
||||
subscriptionId: schema.organizationPlan.subscriptionId,
|
||||
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
|
||||
}
|
||||
})
|
||||
.from(schema.organization)
|
||||
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
|
||||
.where(whereClause);
|
||||
|
||||
// Group by organization and collect plans into an array
|
||||
const organizationMap = new Map<
|
||||
string,
|
||||
{
|
||||
organization: typeof schema.organization.$inferSelect;
|
||||
plans: Array<OrganizationPlan>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of result) {
|
||||
const orgId = row.organization.id;
|
||||
|
||||
if (!organizationMap.has(orgId)) {
|
||||
organizationMap.set(orgId, {
|
||||
organization: row.organization,
|
||||
plans: []
|
||||
});
|
||||
}
|
||||
|
||||
const orgData = organizationMap.get(orgId)!;
|
||||
|
||||
// Add plan to array if it exists (not null)
|
||||
if (
|
||||
row.plan &&
|
||||
(row.plan.planName !== null ||
|
||||
row.plan.isActive !== null ||
|
||||
row.plan.provider !== null ||
|
||||
row.plan.subscriptionId !== null)
|
||||
) {
|
||||
orgData.plans.push({
|
||||
planName: row.plan.planName,
|
||||
isActive: row.plan.isActive,
|
||||
provider: row.plan.provider,
|
||||
subscriptionId: row.plan.subscriptionId,
|
||||
customerId: row.plan.customerId as string | null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(organizationMap.values()).map(({ organization, plans }) => ({
|
||||
...(organization as TOrganization),
|
||||
plans
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new organization plan
|
||||
* @param data Organization plan creation data
|
||||
* @returns Created organization plan record
|
||||
*/
|
||||
export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
|
||||
const [plan] = await db.insert(schema.organizationPlan).values(data).returning();
|
||||
return plan;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an organization plan by subscription ID
|
||||
* @param subscriptionId Subscription ID
|
||||
* @param payload Payload data to update
|
||||
* @returns Updated organization plan record
|
||||
*/
|
||||
export const updateOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
|
||||
const [plan] = await db
|
||||
.update(schema.organizationPlan)
|
||||
.set({ payload, updatedAt: sql`timezone('utc'::text, now())` })
|
||||
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
|
||||
.returning();
|
||||
return plan;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels an organization plan by subscription ID
|
||||
* @param subscriptionId Subscription ID
|
||||
* @param payload Payload data to update
|
||||
* @returns Updated organization plan record
|
||||
*/
|
||||
export const cancelOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
|
||||
const [plan] = await db
|
||||
.update(schema.organizationPlan)
|
||||
.set({
|
||||
isActive: false,
|
||||
deactivatedAt: sql`timezone('utc'::text, now())`,
|
||||
payload,
|
||||
updatedAt: sql`timezone('utc'::text, now())`
|
||||
})
|
||||
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
|
||||
.returning();
|
||||
|
||||
return plan;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { TOrganization, TOrganizationPlan, TOrganizationmember } from '@db/types';
|
||||
|
||||
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
|
||||
customerId: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationWithMemberAndPlans = TOrganization & {
|
||||
roleId?: number;
|
||||
plans: OrganizationPlan[];
|
||||
};
|
||||
|
||||
export type OrganizationWithPlans = TOrganization & {
|
||||
plans: OrganizationPlan[];
|
||||
};
|
||||
@@ -32,11 +32,11 @@ import {
|
||||
quizPlay,
|
||||
role,
|
||||
submission,
|
||||
submissionstatus
|
||||
submissionstatus,
|
||||
user
|
||||
} from './schema';
|
||||
|
||||
import { relations } from 'drizzle-orm/relations';
|
||||
import { user } from './auth-schema';
|
||||
|
||||
export const analyticsLoginEventsRelations = relations(analyticsLoginEvents, ({ one }) => ({
|
||||
usersInAuth: one(user, {
|
||||
|
||||
+83
-24
@@ -1,4 +1,3 @@
|
||||
import { account, session, user } from './auth-schema';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
@@ -27,7 +26,83 @@ export const courseVersion = pgEnum('COURSE_VERSION', ['V1', 'V2']);
|
||||
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
|
||||
|
||||
export { user, session, account };
|
||||
export const user = pgTable('user', {
|
||||
id: uuid()
|
||||
.default(sql`uuid_generate_v4()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
role: text('role'),
|
||||
banned: boolean('banned').default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: timestamp('ban_expires'),
|
||||
isAnonymous: boolean('is_anonymous')
|
||||
});
|
||||
|
||||
export const session = pgTable('session', {
|
||||
id: uuid()
|
||||
.default(sql`uuid_generate_v4()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
impersonatedBy: text('impersonated_by')
|
||||
});
|
||||
|
||||
export const account = pgTable('account', {
|
||||
id: uuid('id')
|
||||
.default(sql`uuid_generate_v4()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const verification = pgTable('verification', {
|
||||
id: uuid('id')
|
||||
.default(sql`uuid_generate_v4()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const analyticsLoginEvents = pgTable(
|
||||
'analytics_login_events',
|
||||
@@ -127,7 +202,6 @@ export const groupAttendance = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
@@ -170,7 +244,6 @@ export const currency = pgTable('currency', {
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
@@ -186,7 +259,6 @@ export const appsPollOption = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -250,6 +322,8 @@ export const profile = pgTable(
|
||||
}),
|
||||
unique('profile_username_key').on(table.username),
|
||||
unique('profile_email_key').on(table.email),
|
||||
index('idx_profile_id').on(table.id),
|
||||
index('idx_profile_email').on(table.email),
|
||||
pgPolicy('Only auth users can read profile', {
|
||||
as: 'permissive',
|
||||
for: 'select',
|
||||
@@ -272,7 +346,6 @@ export const option = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
label: varchar().notNull(),
|
||||
@@ -310,7 +383,6 @@ export const quizPlay = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
@@ -351,7 +423,6 @@ export const organizationContacts = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -385,7 +456,6 @@ export const lessonComment = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -511,7 +581,6 @@ export const submissionstatus = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
label: varchar().notNull(),
|
||||
@@ -619,7 +688,6 @@ export const videoTranscripts = pgTable('video_transcripts', {
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -638,7 +706,6 @@ export const organizationEmaillist = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -720,7 +787,6 @@ export const appsPollSubmission = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -839,7 +905,6 @@ export const lessonCompletion = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -925,7 +990,6 @@ export const communityQuestion = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
@@ -1021,7 +1085,6 @@ export const courseNewsfeedComment = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
courseNewsfeedId: uuid('course_newsfeed_id')
|
||||
@@ -1066,7 +1129,6 @@ export const organizationPlan = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
activatedAt: timestamp('activated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
@@ -1093,6 +1155,7 @@ export const organizationPlan = pgTable(
|
||||
name: 'organization_plan_triggered_by_fkey'
|
||||
}),
|
||||
unique('organization_plan_subscription_id_key').on(table.subscriptionId),
|
||||
index('idx_organization_plan_org_id').on(table.orgId),
|
||||
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
|
||||
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
|
||||
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
|
||||
@@ -1109,7 +1172,6 @@ export const lessonLanguage = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
content: text(),
|
||||
@@ -1171,7 +1233,6 @@ export const organizationmember = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
organizationId: uuid('organization_id').notNull(),
|
||||
@@ -1200,6 +1261,9 @@ export const organizationmember = pgTable(
|
||||
foreignColumns: [role.id],
|
||||
name: 'organizationmember_role_id_fkey'
|
||||
}),
|
||||
index('idx_organizationmember_profile_id').on(table.profileId),
|
||||
index('idx_organizationmember_organization_id').on(table.organizationId),
|
||||
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
|
||||
pgPolicy('Allow authenticated users to read.', {
|
||||
as: 'permissive',
|
||||
for: 'select',
|
||||
@@ -1227,7 +1291,6 @@ export const question = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
@@ -1273,7 +1336,6 @@ export const questionAnswer = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
answers: varchar().array(),
|
||||
@@ -1322,7 +1384,6 @@ export const questionType = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
label: varchar().notNull(),
|
||||
@@ -1346,7 +1407,6 @@ export const role = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
@@ -1366,7 +1426,6 @@ export const waitinglist = pgTable(
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 9223372036854775807,
|
||||
cache: 1
|
||||
}),
|
||||
email: varchar().notNull(),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { db, questionType, role, submissionstatus } from '../drizzle';
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
console.log('🌱 Starting seed...');
|
||||
|
||||
// Seed roles
|
||||
console.log('📝 Seeding roles...');
|
||||
const existingRoles = await db.select().from(role);
|
||||
const existingRoleIds = existingRoles.map((r) => r.id);
|
||||
|
||||
const rolesToInsert = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'ADMIN',
|
||||
description: 'The main controller'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'TUTOR',
|
||||
description: 'Can make changes to content, courses, but cant control passwords and cant add other tutors'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'STUDENT',
|
||||
description: 'A student role, can interact with application but cant make changes'
|
||||
}
|
||||
].filter((r) => !existingRoleIds.includes(r.id));
|
||||
|
||||
if (rolesToInsert.length > 0) {
|
||||
await db.insert(role).values(rolesToInsert);
|
||||
console.log(` ✓ Inserted ${rolesToInsert.length} role(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Roles already exist, skipping');
|
||||
}
|
||||
|
||||
// Seed submission statuses
|
||||
console.log('📝 Seeding submission statuses...');
|
||||
const existingStatuses = await db.select().from(submissionstatus);
|
||||
const statusLabels = existingStatuses.map((s) => s.label);
|
||||
|
||||
const statusesToInsert = [
|
||||
{
|
||||
label: 'Submitted'
|
||||
},
|
||||
{
|
||||
label: 'In Progress'
|
||||
},
|
||||
{
|
||||
label: 'Graded'
|
||||
}
|
||||
].filter((s) => !statusLabels.includes(s.label));
|
||||
|
||||
if (statusesToInsert.length > 0) {
|
||||
await db.insert(submissionstatus).values(statusesToInsert);
|
||||
console.log(` ✓ Inserted ${statusesToInsert.length} submission status(es)`);
|
||||
} else {
|
||||
console.log(' ✓ Submission statuses already exist, skipping');
|
||||
}
|
||||
|
||||
// Seed question types
|
||||
console.log('📝 Seeding question types...');
|
||||
const existingQuestionTypes = await db.select().from(questionType);
|
||||
const questionTypeNames = existingQuestionTypes.map((q) => q.typename);
|
||||
|
||||
const questionTypesToInsert = [
|
||||
{
|
||||
label: 'Single answer',
|
||||
typename: 'RADIO',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:08.12069+00'
|
||||
},
|
||||
{
|
||||
label: 'Multiple answers',
|
||||
typename: 'CHECKBOX',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:27.935478+00'
|
||||
},
|
||||
{
|
||||
label: 'Paragraph',
|
||||
typename: 'TEXTAREA',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:38.634665+00'
|
||||
}
|
||||
].filter((q) => !questionTypeNames.includes(q.typename));
|
||||
|
||||
if (questionTypesToInsert.length > 0) {
|
||||
await db.insert(questionType).values(questionTypesToInsert);
|
||||
console.log(` ✓ Inserted ${questionTypesToInsert.length} question type(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Question types already exist, skipping');
|
||||
}
|
||||
|
||||
console.log('✅ Seed completed successfully!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,139 @@
|
||||
// Database types generated from schema using Drizzle's built-in type inference
|
||||
// This file contains all TypeScript types for the database
|
||||
// Naming convention: T prefix for types
|
||||
|
||||
import * as schema from './schema';
|
||||
|
||||
export type TUser = typeof schema.user.$inferSelect;
|
||||
export type TNewUser = typeof schema.user.$inferInsert;
|
||||
|
||||
export type TSession = typeof schema.session.$inferSelect;
|
||||
export type TNewSession = typeof schema.session.$inferInsert;
|
||||
|
||||
export type TAccount = typeof schema.account.$inferSelect;
|
||||
export type TNewAccount = typeof schema.account.$inferInsert;
|
||||
|
||||
export type TVerification = typeof schema.verification.$inferSelect;
|
||||
export type TNewVerification = typeof schema.verification.$inferInsert;
|
||||
|
||||
export type TAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferSelect;
|
||||
export type TNewAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferInsert;
|
||||
|
||||
export type TLessonSection = typeof schema.lessonSection.$inferSelect;
|
||||
export type TNewLessonSection = typeof schema.lessonSection.$inferInsert;
|
||||
|
||||
export type TGroup = typeof schema.group.$inferSelect;
|
||||
export type TNewGroup = typeof schema.group.$inferInsert;
|
||||
|
||||
export type TGroupAttendance = typeof schema.groupAttendance.$inferSelect;
|
||||
export type TNewGroupAttendance = typeof schema.groupAttendance.$inferInsert;
|
||||
|
||||
export type TCurrency = typeof schema.currency.$inferSelect;
|
||||
export type TNewCurrency = typeof schema.currency.$inferInsert;
|
||||
|
||||
export type TAppsPollOption = typeof schema.appsPollOption.$inferSelect;
|
||||
export type TNewAppsPollOption = typeof schema.appsPollOption.$inferInsert;
|
||||
|
||||
export type TProfile = typeof schema.profile.$inferSelect;
|
||||
export type TNewProfile = typeof schema.profile.$inferInsert;
|
||||
|
||||
export type TOption = typeof schema.option.$inferSelect;
|
||||
export type TNewOption = typeof schema.option.$inferInsert;
|
||||
|
||||
export type TQuizPlay = typeof schema.quizPlay.$inferSelect;
|
||||
export type TNewQuizPlay = typeof schema.quizPlay.$inferInsert;
|
||||
|
||||
export type TOrganizationContacts = typeof schema.organizationContacts.$inferSelect;
|
||||
export type TNewOrganizationContacts = typeof schema.organizationContacts.$inferInsert;
|
||||
|
||||
export type TLessonComment = typeof schema.lessonComment.$inferSelect;
|
||||
export type TNewLessonComment = typeof schema.lessonComment.$inferInsert;
|
||||
|
||||
export type TQuiz = typeof schema.quiz.$inferSelect;
|
||||
export type TNewQuiz = typeof schema.quiz.$inferInsert;
|
||||
|
||||
export type TSubmission = typeof schema.submission.$inferSelect;
|
||||
export type TNewSubmission = typeof schema.submission.$inferInsert;
|
||||
|
||||
export type TSubmissionstatus = typeof schema.submissionstatus.$inferSelect;
|
||||
export type TNewSubmissionstatus = typeof schema.submissionstatus.$inferInsert;
|
||||
|
||||
export type TCourse = typeof schema.course.$inferSelect;
|
||||
export type TNewCourse = typeof schema.course.$inferInsert;
|
||||
|
||||
export type TAppsPoll = typeof schema.appsPoll.$inferSelect;
|
||||
export type TNewAppsPoll = typeof schema.appsPoll.$inferInsert;
|
||||
|
||||
export type TVideoTranscripts = typeof schema.videoTranscripts.$inferSelect;
|
||||
export type TNewVideoTranscripts = typeof schema.videoTranscripts.$inferInsert;
|
||||
|
||||
export type TOrganizationEmaillist = typeof schema.organizationEmaillist.$inferSelect;
|
||||
export type TNewOrganizationEmaillist = typeof schema.organizationEmaillist.$inferInsert;
|
||||
|
||||
export type TLesson = typeof schema.lesson.$inferSelect;
|
||||
export type TNewLesson = typeof schema.lesson.$inferInsert;
|
||||
|
||||
export type TAppsPollSubmission = typeof schema.appsPollSubmission.$inferSelect;
|
||||
export type TNewAppsPollSubmission = typeof schema.appsPollSubmission.$inferInsert;
|
||||
|
||||
export type TExercise = typeof schema.exercise.$inferSelect;
|
||||
export type TNewExercise = typeof schema.exercise.$inferInsert;
|
||||
|
||||
export type TGroupmember = typeof schema.groupmember.$inferSelect;
|
||||
export type TNewGroupmember = typeof schema.groupmember.$inferInsert;
|
||||
|
||||
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
|
||||
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
|
||||
|
||||
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
|
||||
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;
|
||||
|
||||
export type TCommunityQuestion = typeof schema.communityQuestion.$inferSelect;
|
||||
export type TNewCommunityQuestion = typeof schema.communityQuestion.$inferInsert;
|
||||
|
||||
export type TCourseNewsfeed = typeof schema.courseNewsfeed.$inferSelect;
|
||||
export type TNewCourseNewsfeed = typeof schema.courseNewsfeed.$inferInsert;
|
||||
|
||||
export type TCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferSelect;
|
||||
export type TNewCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferInsert;
|
||||
|
||||
export type TTestTenant = typeof schema.testTenant.$inferSelect;
|
||||
export type TNewTestTenant = typeof schema.testTenant.$inferInsert;
|
||||
|
||||
export type TOrganizationPlan = typeof schema.organizationPlan.$inferSelect;
|
||||
export type TNewOrganizationPlan = typeof schema.organizationPlan.$inferInsert;
|
||||
|
||||
export type TLessonLanguage = typeof schema.lessonLanguage.$inferSelect;
|
||||
export type TNewLessonLanguage = typeof schema.lessonLanguage.$inferInsert;
|
||||
|
||||
export type TLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferSelect;
|
||||
export type TNewLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferInsert;
|
||||
|
||||
export type TOrganizationmember = typeof schema.organizationmember.$inferSelect;
|
||||
export type TNewOrganizationmember = typeof schema.organizationmember.$inferInsert;
|
||||
|
||||
export type TQuestion = typeof schema.question.$inferSelect;
|
||||
export type TNewQuestion = typeof schema.question.$inferInsert;
|
||||
|
||||
export type TQuestionAnswer = typeof schema.questionAnswer.$inferSelect;
|
||||
export type TNewQuestionAnswer = typeof schema.questionAnswer.$inferInsert;
|
||||
|
||||
export type TQuestionType = typeof schema.questionType.$inferSelect;
|
||||
export type TNewQuestionType = typeof schema.questionType.$inferInsert;
|
||||
|
||||
export type TRole = typeof schema.role.$inferSelect;
|
||||
export type TNewRole = typeof schema.role.$inferInsert;
|
||||
|
||||
export type TWaitinglist = typeof schema.waitinglist.$inferSelect;
|
||||
export type TNewWaitinglist = typeof schema.waitinglist.$inferInsert;
|
||||
|
||||
export type TOrganization = typeof schema.organization.$inferSelect;
|
||||
export type TNewOrganization = typeof schema.organization.$inferInsert;
|
||||
|
||||
export type TCourseType = (typeof schema.courseType.enumValues)[number];
|
||||
|
||||
export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
|
||||
|
||||
export type TLocale = (typeof schema.locale.enumValues)[number];
|
||||
|
||||
export type TPlan = (typeof schema.plan.enumValues)[number];
|
||||
Reference in New Issue
Block a user