diff --git a/apps/backend/src/migrations/0026-add-users-groups-table.ts b/apps/backend/src/migrations/0026-add-users-groups-table.ts index 4bab9537..0cfb9eb4 100644 --- a/apps/backend/src/migrations/0026-add-users-groups-table.ts +++ b/apps/backend/src/migrations/0026-add-users-groups-table.ts @@ -1,4 +1,4 @@ -import type { Kysely } from 'kysely'; +import { type Kysely, sql } from 'kysely'; import { addArchivedAtColumns, addIdColumn, addTimestampColumns } from './utils'; @@ -25,9 +25,36 @@ export async function up(db: Kysely): Promise { .on('users_groups') .column('organization_id') .execute(); + + await sql` + CREATE OR REPLACE FUNCTION users_groups_users_check_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM organizations_users ou + JOIN users_groups ug ON ug.organization_id = ou.organization_id + WHERE ou.user_id = NEW.user_id + AND ug.id = NEW.group_id + ) THEN + RAISE EXCEPTION 'User must belong to the same organization as the group'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER users_groups_users_organization_check + BEFORE INSERT OR UPDATE ON users_groups_users + FOR EACH ROW + EXECUTE FUNCTION users_groups_users_check_organization(); + `.execute(db); } export async function down(db: Kysely): Promise { + await sql` + DROP TRIGGER IF EXISTS users_groups_users_organization_check ON users_groups_users; + DROP FUNCTION IF EXISTS users_groups_users_check_organization(); + `.execute(db); + await db.schema.dropTable('users_groups_users').execute(); await db.schema.dropTable('users_groups').execute(); } diff --git a/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts b/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts index 57677ddb..d804183f 100644 --- a/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts +++ b/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts @@ -1,7 +1,55 @@ +import { pipe } from 'fp-ts/lib/function'; import { injectable } from 'tsyringe'; -import { AbstractDatabaseRepo } from '~/modules/database'; +import { + AbstractDatabaseRepo, + DatabaseError, + TableRowWithId, + TransactionalAttrs, + tryReuseOrCreateTransaction, +} from '~/modules/database'; @injectable() export class UsersGroupsUsersRepo extends AbstractDatabaseRepo { + updateGroupUsers = ( + { + forwardTransaction, + group, + users, + }: TransactionalAttrs<{ + group: TableRowWithId; + users: TableRowWithId[]; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + async () => { + await trx + .deleteFrom('users_groups_users') + .where('group_id', '=', group.id) + .execute(); + + if (users.length > 0) { + await trx + .insertInto('users_groups_users') + .values( + users.map(user => ({ + group_id: group.id, + user_id: user.id, + })), + ) + .execute(); + } + + return { + success: true, + }; + }, + DatabaseError.tryTask, + )); + }; } diff --git a/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts b/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts index e6c799ff..7a1819ce 100644 --- a/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts +++ b/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts @@ -2,31 +2,46 @@ import camelcaseKeys from 'camelcase-keys'; import { array as A, taskEither as TE } from 'fp-ts'; import { pipe } from 'fp-ts/lib/function'; import { jsonBuildObject } from 'kysely/helpers/postgres'; -import { injectable } from 'tsyringe'; +import { inject, injectable } from 'tsyringe'; + +import type { SdkCreateUsersGroupInputT, SdkUpdateUsersGroupInputT } from '@llm/sdk'; import type { UsersGroupTableRowWithRelations } from '../users-groups.tables'; import { createArchiveRecordQuery, createArchiveRecordsQuery, - createDatabaseRepo, + createProtectedDatabaseRepo, createUnarchiveRecordQuery, createUnarchiveRecordsQuery, + DatabaseConnectionRepo, DatabaseError, type TableId, + type TableRowWithId, type TransactionalAttrs, + tryReuseOrCreateTransaction, tryReuseTransactionOrSkip, } from '../../database'; +import { UsersGroupsUsersRepo } from './users-groups-users.repo'; @injectable() -export class UsersGroupsRepo extends createDatabaseRepo('users_groups') { - archive = createArchiveRecordQuery(this.queryFactoryAttrs); +export class UsersGroupsRepo extends createProtectedDatabaseRepo('users_groups') { + constructor( + @inject(DatabaseConnectionRepo) connectionRepo: DatabaseConnectionRepo, + @inject(UsersGroupsUsersRepo) private readonly usersGroupsUsersRepo: UsersGroupsUsersRepo, + ) { + super(connectionRepo); + } + + createIdsIterator = this.baseRepo.createIdsIterator; + + archive = createArchiveRecordQuery(this.baseRepo.queryFactoryAttrs); - archiveRecords = createArchiveRecordsQuery(this.queryFactoryAttrs); + archiveRecords = createArchiveRecordsQuery(this.baseRepo.queryFactoryAttrs); - unarchive = createUnarchiveRecordQuery(this.queryFactoryAttrs); + unarchive = createUnarchiveRecordQuery(this.baseRepo.queryFactoryAttrs); - unarchiveRecords = createUnarchiveRecordsQuery(this.queryFactoryAttrs); + unarchiveRecords = createUnarchiveRecordsQuery(this.baseRepo.queryFactoryAttrs); findWithRelationsByIds = ({ forwardTransaction, ids }: TransactionalAttrs<{ ids: TableId[]; }>) => { const transaction = tryReuseTransactionOrSkip({ db: this.db, forwardTransaction }); @@ -93,4 +108,73 @@ export class UsersGroupsRepo extends createDatabaseRepo('users_groups') { ), ); }; + + create = ( + { + forwardTransaction, + value: { + users, + creator, + organization, + ...attrs + }, + }: TransactionalAttrs<{ + value: SdkCreateUsersGroupInputT & { + creator: TableRowWithId; + }; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + this.baseRepo.create({ + value: { + ...attrs, + creatorUserId: creator.id, + organizationId: organization.id, + }, + forwardTransaction: trx, + }), + TE.tap(group => this.usersGroupsUsersRepo.updateGroupUsers({ + forwardTransaction: trx, + group, + users, + })), + )); + }; + + update = ( + { + forwardTransaction, + id, + value: { + users, + ...attrs + }, + }: TransactionalAttrs<{ + id: TableId; + value: SdkUpdateUsersGroupInputT; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + this.baseRepo.update({ + id, + value: attrs, + forwardTransaction: trx, + }), + TE.tap(group => this.usersGroupsUsersRepo.updateGroupUsers({ + forwardTransaction: trx, + group, + users, + })), + )); + }; } diff --git a/apps/backend/src/modules/users-groups/users-groups.service.ts b/apps/backend/src/modules/users-groups/users-groups.service.ts index f0fb80fc..2ad6d087 100644 --- a/apps/backend/src/modules/users-groups/users-groups.service.ts +++ b/apps/backend/src/modules/users-groups/users-groups.service.ts @@ -74,20 +74,12 @@ export class UsersGroupsService implements WithAuthFirewall ); create = ( - { - creator, - organization, - ...values - }: SdkCreateUsersGroupInputT & { + value: SdkCreateUsersGroupInputT & { creator: TableRowWithId; }, ) => pipe( this.repo.create({ - value: { - ...values, - creatorUserId: creator.id, - organizationId: organization.id, - }, + value, }), TE.tap(({ id }) => this.esIndexRepo.findAndIndexDocumentById(id)), );