feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
+10
View File
@@ -33,3 +33,13 @@ Now when I am ready to deploy the feature, I can use [option 2](https://orm.driz
## OR
I can make changes directly to the schema file and run `pnpm db push` to push the changes to the source db.
## Change email flow
1. User changes email on dashboard to new email
2. We send an email verification to the current, permitting the change
3. Meanwhile the frontend reverts to the previous email.
4. User clicks approve link in email
5. User is redirected to settings page with message that another email has been sent to the new email
6. User goes to new email inbox and clicks approve link in email
7. User is taken to the settings page with new email address.
+1
View File
@@ -11,6 +11,7 @@
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"db": "drizzle-kit",
"db:setup": "tsx src/scripts/db-setup.ts",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts",
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
+3 -2
View File
@@ -15,8 +15,8 @@ export const auth = betterAuth({
baseURL: CONSTANTS.BASE_URL,
database: drizzleAdapter(db, {
provider: 'pg',
schema,
debugLogs: true
schema
// debugLogs: true
}),
emailAndPassword: emailAndPassword,
user: {
@@ -67,6 +67,7 @@ export const auth = betterAuth({
},
update: {
after: async (user) => {
console.log('update user', user);
await syncUserWithProfile(user);
}
}
+6 -3
View File
@@ -28,17 +28,20 @@ type ChangeEmailConfirmationOptions = Parameters<
*/
export const sendVerificationEmail = async (options: EmailVerificationOptions) => {
const { user, url } = options;
console.log('\nsendVerificationEmail');
console.log('\nsendVerificationEmail', options);
// 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;
}
// Remove trigger=app or trigger%3Dapp from the URL
const link = url.replace('trigger=app', '').replace('trigger%3Dapp', '');
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
link,
userName: user.name || undefined
}
});
@@ -51,7 +54,7 @@ export const sendVerificationEmail = async (options: EmailVerificationOptions) =
*/
export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmationOptions) => {
const { user, newEmail, url } = options;
console.log('\nsendChangeEmailConfirmation');
console.log('\nsendChangeEmailConfirmation', options);
await sendEmail('verifyEmail', {
to: user.email,
+77 -3
View File
@@ -2,8 +2,8 @@ import * as schema from '@db/schema';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { TNewCourse } from '@db/types';
import { db } from '@db/drizzle';
/**
* Gets courses by organization siteName
@@ -50,15 +50,89 @@ export const getCoursesBySiteName = async (siteName: string) => {
}
}));
} catch (error) {
throw new Error(`Failed to get courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to get courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Gets lessons by organization siteName
* @param siteName Organization site name
* @returns Array of lessons
*/
export const getLessonsBySiteName = async (siteName: string) => {
const result = await db
.select({
lesson: schema.lesson
})
.from(schema.lesson)
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => row.lesson);
};
/**
* Gets exercises by organization siteName
* @param siteName Organization site name
* @returns Array of exercises
*/
export const getExercisesBySiteName = async (siteName: string) => {
const result = await db
.select({
exercise: schema.exercise
})
.from(schema.exercise)
.innerJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => row.exercise);
};
/**
* Gets courses by organization siteName (for setup - includes unpublished courses)
* @param siteName Organization site name
* @returns Array of courses with organization info
*/
export const getCoursesBySiteNameForSetup = 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
}
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => ({
...row.course,
group: {
organization: row.organization
}
}));
};
export async function getCourseById(courseId: string) {
try {
return await db.select().from(schema.course).where(eq(schema.course.id, courseId)).limit(1);
} catch (error) {
throw new Error(`Failed to get course by ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to get course by ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -8,7 +8,7 @@ import type {
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -90,6 +90,17 @@ export const createOrganizationMember = async (data: TNewOrganizationmember) =>
return member;
};
/**
* Creates multiple organization members in a single query
* @param data Array of organization member creation data
* @returns Array of created members
*/
export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => {
const members = await db.insert(schema.organizationmember).values(data).returning();
return members;
};
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
@@ -102,10 +113,132 @@ export const checkSiteNameExists = async (siteName: string): Promise<boolean> =>
return result.length > 0;
};
/**
* Gets an organization by siteName
* @param siteName Organization site name
* @returns Organization or null if not found
*/
export const getOrganizationBySiteName = async (siteName: string): Promise<TOrganization | null> => {
const [organization] = await db
.select()
.from(schema.organization)
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return organization || null;
};
/**
* Gets an organization by ID
* @param id Organization ID
* @returns Organization or null if not found
*/
export const getOrganizationById = async (id: string): Promise<TOrganization | null> => {
const [organization] = await db.select().from(schema.organization).where(eq(schema.organization.id, id)).limit(1);
return organization || null;
};
export const deleteOrganizationById = async (id: string) => {
await db.delete(schema.organization).where(eq(schema.organization.id, id));
};
/**
* Checks if an email already exists as a team member in an organization
* @param orgId Organization ID
* @param email Email address to check
* @returns True if email exists, false otherwise
*/
export const checkEmailExistsInOrg = async (orgId: string, email: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organizationmember.id })
.from(schema.organizationmember)
.where(
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, email.toLowerCase()))
)
.limit(1);
return result.length > 0;
};
/**
* Checks which emails already exist as team members in an organization (bulk check)
* Checks both organizationmember.email and profile.email (via organizationmember.profileId)
* @param orgId Organization ID
* @param emails Array of email addresses to check (should already be normalized)
* @returns Array of emails that already exist in the organization
*/
export const checkEmailsExistInOrg = async (orgId: string, emails: string[]): Promise<string[]> => {
if (emails.length === 0) {
return [];
}
const [orgMemberEmails, profileEmails] = await Promise.all([
db
.select({ email: schema.organizationmember.email })
.from(schema.organizationmember)
.where(
and(eq(schema.organizationmember.organizationId, orgId), inArray(schema.organizationmember.email, emails))
),
db
.select({ email: schema.profile.email })
.from(schema.organizationmember)
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(and(eq(schema.organizationmember.organizationId, orgId), inArray(schema.profile.email, emails)))
]);
const existingEmails = new Set<string>();
orgMemberEmails.forEach((r) => {
if (r.email) {
existingEmails.add(r.email.toLowerCase());
}
});
profileEmails.forEach((r) => {
if (r.email) {
existingEmails.add(r.email.toLowerCase());
}
});
return Array.from(existingEmails);
};
/**
* Deletes an organization member by ID
* @param orgId Organization ID
* @param memberId Member ID to delete
* @returns Deleted member or null if not found
*/
export const deleteOrganizationMember = async (orgId: string, memberId: number) => {
const [deleted] = await db
.delete(schema.organizationmember)
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.id, memberId)))
.returning();
return deleted || null;
};
/**
* Checks if a user is an admin of an organization
* @param orgId Organization ID
* @param profileId Profile ID to check
* @returns True if user is admin, false otherwise
*/
export const isUserOrgAdmin = async (orgId: string, profileId: string): Promise<boolean> => {
const result = await db
.select({ roleId: schema.organizationmember.roleId })
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.limit(1);
return result.length > 0;
};
/**
* Gets organization team members (non-students)
* @param orgId Organization ID
@@ -167,8 +300,8 @@ export const getOrganizationAudience = async (orgId: string) => {
id: profile.id,
name: profile.fullname,
email: profile.email || '',
avatar_url: profile.avatarUrl || '',
date_joined: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
avatarUrl: profile.avatarUrl || '',
createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
}));
};
@@ -265,7 +398,7 @@ export const getOrganizations = async (filters?: {
* @param data Organization plan creation data
* @returns Created organization plan record
*/
export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
export const createOrganizationPlan = async (data: TNewOrganizationPlan): Promise<TOrganizationPlan> => {
const [plan] = await db.insert(schema.organizationPlan).values(data).returning();
return plan;
};
@@ -276,7 +409,10 @@ export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const updateOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
export const updateOrganizationPlan = async (
subscriptionId: string,
payload: TOrganizationPlan['payload']
): Promise<TOrganizationPlan> => {
const [plan] = await db
.update(schema.organizationPlan)
.set({ payload, updatedAt: sql`timezone('utc'::text, now())` })
@@ -291,7 +427,10 @@ export const updateOrganizationPlan = async (subscriptionId: string, payload: TO
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const cancelOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
export const cancelOrganizationPlan = async (
subscriptionId: string,
payload: TOrganizationPlan['payload']
): Promise<TOrganizationPlan> => {
const [plan] = await db
.update(schema.organizationPlan)
.set({
@@ -305,3 +444,19 @@ export const cancelOrganizationPlan = async (subscriptionId: string, payload: TO
return plan;
};
/**
* Updates an organization
* @param id Organization ID
* @param data Partial organization data to update
* @returns Updated organization record
*/
export const updateOrganization = async (id: string, data: Partial<TOrganization>): Promise<TOrganization> => {
const [organization] = await db
.update(schema.organization)
.set(data)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
@@ -1,4 +1,4 @@
import { TOrganization, TOrganizationPlan, TOrganizationmember } from '@db/types';
import { TOrganization, TOrganizationPlan } from '@db/types';
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
customerId: string | null;
+139 -323
View File
@@ -8,7 +8,6 @@ import {
json,
jsonb,
pgEnum,
pgPolicy,
pgTable,
pgView,
serial,
@@ -28,7 +27,7 @@ export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const user = pgTable('user', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: text('name').notNull(),
@@ -49,7 +48,7 @@ export const user = pgTable('user', {
export const session = pgTable('session', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
expiresAt: timestamp('expires_at').notNull(),
@@ -68,7 +67,7 @@ export const session = pgTable('session', {
export const account = pgTable('account', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
accountId: text('account_id').notNull(),
@@ -91,7 +90,7 @@ export const account = pgTable('account', {
export const verification = pgTable('verification', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
identifier: text('identifier').notNull(),
@@ -108,7 +107,7 @@ export const analyticsLoginEvents = pgTable(
'analytics_login_events',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
userId: uuid('user_id').notNull(),
@@ -125,14 +124,7 @@ export const analyticsLoginEvents = pgTable(
foreignColumns: [user.id],
name: 'analytics_login_events_user_id_fkey'
}),
unique('analytics_login_events_user_id_unique').on(table.userId),
pgPolicy('Enable delete for users based on user_id', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = user_id)`
}),
pgPolicy('Users can insert their own login events', { as: 'permissive', for: 'insert', to: ['authenticated'] })
unique('analytics_login_events_user_id_unique').on(table.userId)
]
);
@@ -154,11 +146,7 @@ export const lessonSection = pgTable(
name: 'public_lesson_section_course_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -166,7 +154,7 @@ export const group = pgTable(
'group',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: varchar().notNull(),
@@ -180,16 +168,7 @@ export const group = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'group_organization_id_fkey'
}),
pgPolicy('Enable insert for authenticated users only', {
as: 'permissive',
for: 'insert',
to: ['authenticated'],
withCheck: sql`true`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('Only org admins can delete', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Only org admins can update', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -221,19 +200,7 @@ export const groupAttendance = pgTable(
columns: [table.studentId],
foreignColumns: [groupmember.id],
name: 'group_attendance_student_id_fkey'
}),
pgPolicy('User must be a course member to INSERT', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`is_user_in_course_group_or_admin(( SELECT course.group_id
FROM course
WHERE (course.id = group_attendance.course_id)
LIMIT 1))`
}),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] })
})
]
);
@@ -271,22 +238,7 @@ export const appsPollOption = pgTable(
columns: [table.pollId],
foreignColumns: [appsPoll.id],
name: 'apps_poll_option_poll_id_fkey'
}),
pgPolicy('User must be a course member to INSERT', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`is_user_in_course_group_or_admin(( SELECT groupmember.group_id
FROM groupmember
WHERE (groupmember.id = ( SELECT apps_poll."authorId"
FROM apps_poll
WHERE (apps_poll.id = apps_poll_option.poll_id)
LIMIT 1))
LIMIT 1))`
}),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a teacher to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -323,17 +275,7 @@ 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',
to: ['anon', 'authenticated'],
using: sql`true`
}),
pgPolicy('Public profiles are viewable by everyone.', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User can only delete their profiles', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Users can insert their own profile.', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('Users can update own profile.', { as: 'permissive', for: 'update', to: ['public'] })
index('idx_profile_email').on(table.email)
]
);
@@ -352,7 +294,7 @@ export const option = pgTable(
isCorrect: boolean('is_correct').default(false).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
questionId: bigint('question_id', { mode: 'number' }).notNull(),
value: uuid().default(sql`extensions.gen_random_uuid()`),
value: uuid().default(sql`gen_random_uuid()`),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
@@ -361,16 +303,7 @@ export const option = pgTable(
columns: [table.questionId],
foreignColumns: [question.id],
name: 'option_question_id_fkey'
}).onDelete('cascade'),
pgPolicy('Allow authenticated users to SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -404,13 +337,7 @@ export const quizPlay = pgTable(
foreignColumns: [quiz.id],
name: 'quiz_play_quiz_id_fkey'
}),
unique('quiz_play_pin_key').on(table.pin),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
unique('quiz_play_pin_key').on(table.pin)
]
);
@@ -437,12 +364,6 @@ export const organizationContacts = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'organization_contacts_organization_id_fkey'
}),
pgPolicy('Enable read access for all users', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`true`
})
]
);
@@ -474,18 +395,7 @@ export const lessonComment = pgTable(
columns: [table.lessonId],
foreignColumns: [lesson.id],
name: 'lesson_comment_lesson_id_fkey'
}).onDelete('cascade'),
pgPolicy('Delete only your own comment', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = lesson_comment.groupmember_id)))`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be in course group to INSERT', { as: 'permissive', for: 'insert', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -493,7 +403,7 @@ export const quiz = pgTable(
'quiz',
{
id: uuid()
.default(sql`extensions.gen_random_uuid()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -509,12 +419,6 @@ export const quiz = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'quiz_organization_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
]
);
@@ -523,7 +427,7 @@ export const submission = pgTable(
'submission',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -559,16 +463,7 @@ export const submission = pgTable(
columns: [table.submittedBy],
foreignColumns: [groupmember.id],
name: 'submission_submitted_by_fkey'
}),
pgPolicy('Only authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
}),
pgPolicy('User must be a course member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -586,14 +481,7 @@ export const submissionstatus = pgTable(
label: varchar().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
]
(table) => []
);
export const course = pgTable(
@@ -603,7 +491,7 @@ export const course = pgTable(
description: varchar().notNull(),
overview: varchar().default('Welcome to this amazing course 🚀 '),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -630,16 +518,7 @@ export const course = pgTable(
foreignColumns: [group.id],
name: 'course_group_id_fkey'
}),
unique('course_slug_key').on(table.slug),
pgPolicy('Enable access to all users if PUBLIC or to course members when ', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(is_published OR is_user_in_course_group_or_admin(group_id))`
}),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('course_slug_key').on(table.slug)
]
);
@@ -666,18 +545,7 @@ export const appsPoll = pgTable(
columns: [table.courseId],
foreignColumns: [course.id],
name: 'apps_poll_courseId_fkey'
}),
pgPolicy('Delete only your own poll', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = apps_poll."authorId")))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -717,12 +585,6 @@ export const organizationEmaillist = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'organization_emaillist_organization_id_fkey'
}),
pgPolicy('Enable read access for all users', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`true`
})
]
);
@@ -735,7 +597,7 @@ export const lesson = pgTable(
slideUrl: varchar('slide_url'),
courseId: uuid('course_id').notNull(),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -770,11 +632,7 @@ export const lesson = pgTable(
name: 'public_lesson_section_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -810,16 +668,7 @@ export const appsPollSubmission = pgTable(
columns: [table.selectedById],
foreignColumns: [groupmember.id],
name: 'apps_poll_submission_selected_by_id_fkey'
}),
pgPolicy('Authenticated users can read', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own submission', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own submission', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -832,7 +681,7 @@ export const exercise = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
dueBy: timestamp('due_by', { mode: 'string' })
@@ -842,11 +691,7 @@ export const exercise = pgTable(
columns: [table.lessonId],
foreignColumns: [lesson.id],
name: 'exercise_lesson_id_fkey'
}).onDelete('cascade'),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -854,7 +699,7 @@ export const groupmember = pgTable(
'groupmember',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
groupId: uuid('group_id').notNull(),
@@ -883,16 +728,7 @@ export const groupmember = pgTable(
}),
unique('unique_entries').on(table.groupId, table.profileId, table.email),
unique('unique_group_email').on(table.groupId, table.email),
unique('unique_group_profile').on(table.groupId, table.profileId),
pgPolicy('Enable insert for authenticated users only', {
as: 'permissive',
for: 'insert',
to: ['authenticated'],
withCheck: sql`true`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('unique_group_profile').on(table.groupId, table.profileId)
]
);
@@ -924,15 +760,7 @@ export const lessonCompletion = pgTable(
foreignColumns: [profile.id],
name: 'lesson_completion_profile_id_fkey'
}),
unique('unique_lesson_profile').on(table.lessonId, table.profileId),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an course member or Admin to perform ALL operation', {
as: 'permissive',
for: 'all',
to: ['public']
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('unique_lesson_profile').on(table.lessonId, table.profileId)
]
);
@@ -940,7 +768,7 @@ export const communityAnswer = pgTable(
'community_answer',
{
id: uuid()
.default(sql`extensions.gen_random_uuid()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -968,16 +796,7 @@ export const communityAnswer = pgTable(
columns: [table.questionId],
foreignColumns: [communityQuestion.id],
name: 'community_answer_question_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own answer', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own answer', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1024,16 +843,7 @@ export const communityQuestion = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'community_question_organization_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own question', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own question', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1058,18 +868,7 @@ export const courseNewsfeed = pgTable(
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_newsfeed_course_id_fkey'
}),
pgPolicy('Delete your own comment', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = course_newsfeed.author_id)))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -1100,18 +899,7 @@ export const courseNewsfeedComment = pgTable(
foreignColumns: [courseNewsfeed.id],
name: 'course_newsfeed_comment_course_newsfeed_id_fkey'
}),
unique('course_newsfeed_comment_id_key').on(table.id),
pgPolicy('Delete your own', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = course_newsfeed_comment.author_id)))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
unique('course_newsfeed_comment_id_key').on(table.id)
]
);
@@ -1155,11 +943,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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
index('idx_organization_plan_org_id').on(table.orgId)
]
);
@@ -1185,11 +969,7 @@ export const lessonLanguage = pgTable(
name: 'public_lesson_language_lesson_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -1211,16 +991,7 @@ export const lessonLanguageHistory = pgTable(
name: 'public_lesson_language_history_lesson_language_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -1263,22 +1034,7 @@ export const organizationmember = pgTable(
}),
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',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Only admin can delete', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Only admin can update', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('Only user can update their account via email', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be an admin to INSERT or allow if no existing member', {
as: 'permissive',
for: 'insert',
to: ['public']
})
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId)
]
);
@@ -1299,7 +1055,7 @@ export const question = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
exerciseId: uuid('exercise_id').notNull(),
name: uuid().default(sql`extensions.gen_random_uuid()`),
name: uuid().default(sql`gen_random_uuid()`),
points: doublePrecision(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' })
@@ -1314,16 +1070,7 @@ export const question = pgTable(
columns: [table.questionTypeId],
foreignColumns: [questionType.id],
name: 'question_question_type_id_fkey'
}),
pgPolicy('Allow authenticated users to SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
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'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1362,16 +1109,7 @@ export const questionAnswer = pgTable(
columns: [table.submissionId],
foreignColumns: [submission.id],
name: 'question_answer_submission_id_fkey'
}).onDelete('cascade'),
pgPolicy('Only authenticated users can select.', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('User must be an course member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -1391,9 +1129,7 @@ export const questionType = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
typename: varchar()
},
(table) => [
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` })
]
(table) => []
);
export const role = pgTable(
@@ -1412,9 +1148,7 @@ export const role = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` })
]
(table) => []
);
export const waitinglist = pgTable(
@@ -1438,14 +1172,88 @@ export const organization = pgTable(
'organization',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: varchar().notNull(),
siteName: text(),
avatarUrl: text('avatar_url'),
settings: jsonb().default({}),
landingpage: jsonb().default({}),
landingpage: jsonb().default({}).$type<{
header?: {
title: string;
titleHighlight: string;
subtitle: string;
action: {
label: string;
link: string;
redirect: boolean;
};
banner: {
video: string;
image: string;
type: string;
show: boolean;
};
background: {
image: string;
show: boolean;
};
show: boolean;
};
aboutUs?: {
title: string;
content: string;
imageUrl: string;
show: boolean;
};
courses?: {
title: string;
titleHighlight: string;
subtitle: string;
show: boolean;
};
faq?: {
title: string;
questions: Array<{
id: number;
title: string;
content: string;
}>;
show: boolean;
};
contact?: {
title: string;
titleHighlight: string;
subtitle: string;
address: string;
phone: string;
email: string;
show: boolean;
};
mailinglist?: {
title: string;
subtitle: string;
buttonLabel: string;
show: boolean;
};
customLinks?: {
show: boolean;
links: Array<{
id: number;
label: string;
url: string;
openInNewTab: boolean;
}>;
};
footer?: {
facebook: string;
instagram: string;
twitter: string;
linkedin: string;
show: boolean;
};
}>(),
theme: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
@@ -1456,6 +1264,22 @@ export const organization = pgTable(
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
})
.$type<{
dashboard: {
community: boolean;
exercise: boolean;
bannerImage: string;
bannerText: string;
};
course: {
newsfeed: boolean;
grading: boolean;
};
apps: {
poll: boolean;
comments: boolean;
};
}>()
.notNull(),
isRestricted: boolean('is_restricted').default(false).notNull(),
customCode: text(),
@@ -1465,18 +1289,10 @@ export const organization = pgTable(
},
(table) => [
unique('organization_siteName_key').on(table.siteName),
unique('organization_customDomain_key').on(table.customDomain),
pgPolicy('Authenticated users can delete', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be admin to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('organization_customDomain_key').on(table.customDomain)
]
);
export const dashOrgStats = pgView('dash_org_stats', {
orgId: uuid('org_id'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
+50
View File
@@ -0,0 +1,50 @@
import 'dotenv/config';
import postgres from 'postgres';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
if (!connectionString) {
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
process.exit(1);
}
async function dbSetup() {
const sql = postgres(connectionString);
try {
console.log('Setting up database roles...');
// Create authenticated role if it doesn't exist (used in RLS policies)
const authenticatedRoleExists = await sql`
SELECT 1 FROM pg_roles WHERE rolname = 'authenticated'
`;
if (authenticatedRoleExists.length === 0) {
await sql`CREATE ROLE authenticated`;
console.log('✓ authenticated role created');
} else {
console.log('✓ authenticated role already exists');
}
// Create anon role if it doesn't exist (used in RLS policies)
const anonRoleExists = await sql`
SELECT 1 FROM pg_roles WHERE rolname = 'anon'
`;
if (anonRoleExists.length === 0) {
await sql`CREATE ROLE anon`;
console.log('✓ anon role created');
} else {
console.log('✓ anon role already exists');
}
// Note: 'public' role exists by default in PostgreSQL
// Add other extensions here as needed
} catch (error) {
console.error('Failed to setup extensions:', error);
process.exit(1);
} finally {
await sql.end();
}
}
dbSetup();
-6
View File
@@ -617,12 +617,6 @@ async function seed() {
// Seed questions
console.log('📝 Seeding questions...');
const targetExerciseIds = [
'e2ea9fb8-6448-4f6c-a1d5-02c2b12cf862',
'e78bfd24-8ac3-43e9-a117-a2f9d00f74b1',
'bd6e81c7-3d28-4037-acf0-a3028c583771',
'd8cd1cf7-1951-46b3-ad1c-41e415185bc1'
];
const existingQuestions = await db
.select()