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
+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()