diff --git a/backend/src/apis/humhub/HumHubClient.ts b/backend/src/apis/humhub/HumHubClient.ts index 3c6b2536f..59268cc9d 100644 --- a/backend/src/apis/humhub/HumHubClient.ts +++ b/backend/src/apis/humhub/HumHubClient.ts @@ -1,9 +1,8 @@ -import { ProjectBranding } from 'database' +import { dbFindProjectSpaceUrl } from 'database' import { SignJWT } from 'jose' import { getLogger } from 'log4js' import { IRequestOptions, IRestResponse, RestClient } from 'typed-rest-client' import { CONFIG } from '@/config' - import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { LogError } from '@/server/LogError' import { PostUserLoggingView } from './logging/PostUserLogging.view' @@ -67,15 +66,9 @@ export class HumHubClient { public async createAutoLoginUrl(username: string, project?: string | null) { const secret = new TextEncoder().encode(CONFIG.HUMHUB_JWT_KEY) logger.info(`user ${username} as username for humhub auto-login`) - let redirectLink: string | undefined + let redirectLink: string | undefined | null if (project) { - const projectBranding = await ProjectBranding.findOne({ - where: { alias: project }, - select: { spaceUrl: true }, - }) - if (projectBranding?.spaceUrl) { - redirectLink = projectBranding.spaceUrl - } + redirectLink = await dbFindProjectSpaceUrl(project) } const token = await new SignJWT({ username, redirectLink }) .setProtectedHeader({ alg: 'HS256' }) diff --git a/backend/src/graphql/model/ProjectBranding.ts b/backend/src/graphql/model/ProjectBranding.ts index c879e918f..591199a63 100644 --- a/backend/src/graphql/model/ProjectBranding.ts +++ b/backend/src/graphql/model/ProjectBranding.ts @@ -1,9 +1,11 @@ -import { ProjectBranding as dbProjectBranding } from 'database' +import { ProjectBrandingSelect } from 'database' +import { ProjectBranding as ProjectBrandingZodSchema } from 'shared' import { Field, Int, ObjectType } from 'type-graphql' @ObjectType() export class ProjectBranding { - constructor(projectBranding: dbProjectBranding) { + // TODO: replace with valibot schema + constructor(projectBranding: ProjectBrandingZodSchema | ProjectBrandingSelect) { Object.assign(this, projectBranding) } diff --git a/backend/src/graphql/resolver/ProjectBrandingResolver.ts b/backend/src/graphql/resolver/ProjectBrandingResolver.ts index 610afa630..2c3b5a08d 100644 --- a/backend/src/graphql/resolver/ProjectBrandingResolver.ts +++ b/backend/src/graphql/resolver/ProjectBrandingResolver.ts @@ -2,8 +2,16 @@ import { ProjectBrandingInput } from '@input/ProjectBrandingInput' import { ProjectBranding } from '@model/ProjectBranding' import { Space } from '@model/Space' import { SpaceList } from '@model/SpaceList' -import { ProjectBranding as DbProjectBranding } from 'database' +import { + dbDeleteProjectBranding, + dbFindAllProjectBrandings, + dbFindProjectBrandingById, + dbGetProjectLogoURL, + dbUpsertProjectBranding, + projectBrandingsTable, +} from 'database' import { getLogger } from 'log4js' +import { projectBrandingSchema } from 'shared' import { Arg, Authorized, ID, Int, Mutation, Query, Resolver } from 'type-graphql' import { HumHubClient } from '@/apis/humhub/HumHubClient' import { RIGHTS } from '@/auth/RIGHTS' @@ -17,15 +25,15 @@ export class ProjectBrandingResolver { @Query(() => [ProjectBranding]) @Authorized([RIGHTS.PROJECT_BRANDING_VIEW]) async projectBrandings(): Promise { - return (await DbProjectBranding.find()).map( - (entity: DbProjectBranding) => new ProjectBranding(entity), + return (await dbFindAllProjectBrandings()).map( + (entity: typeof projectBrandingsTable.$inferSelect) => new ProjectBranding(entity), ) } @Query(() => ProjectBranding) @Authorized([RIGHTS.PROJECT_BRANDING_VIEW]) async projectBranding(@Arg('id', () => Int) id: number): Promise { - const projectBrandingEntity = await DbProjectBranding.findOneBy({ id }) + const projectBrandingEntity = await dbFindProjectBrandingById(id) if (!projectBrandingEntity) { throw new LogError(`Project Branding with id: ${id} not found`) } @@ -35,14 +43,7 @@ export class ProjectBrandingResolver { @Query(() => String, { nullable: true }) @Authorized([RIGHTS.PROJECT_BRANDING_BANNER]) async projectBrandingBanner(@Arg('alias', () => String) alias: string): Promise { - const projectBrandingEntity = await DbProjectBranding.findOne({ - where: { alias }, - select: { id: true, logoUrl: true }, - }) - if (!projectBrandingEntity) { - throw new LogError(`Project Branding with alias: ${alias} not found`) - } - return projectBrandingEntity.logoUrl + return await dbGetProjectLogoURL(alias) } @Mutation(() => ProjectBranding, { nullable: true }) @@ -50,21 +51,14 @@ export class ProjectBrandingResolver { async upsertProjectBranding( @Arg('input') input: ProjectBrandingInput, ): Promise { - const projectBranding = input.id - ? await DbProjectBranding.findOneOrFail({ where: { id: input.id } }) - : new DbProjectBranding() - - Object.assign(projectBranding, input) - await projectBranding.save() - - return new ProjectBranding(projectBranding) + return new ProjectBranding(await dbUpsertProjectBranding(projectBrandingSchema.parse(input))) } @Mutation(() => Boolean) @Authorized([RIGHTS.PROJECT_BRANDING_MUTATE]) async deleteProjectBranding(@Arg('id', () => ID) id: number): Promise { try { - await DbProjectBranding.delete({ id }) + await dbDeleteProjectBranding(id) return true } catch (err) { logger.error(err) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 061498207..fb2481446 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -30,9 +30,11 @@ import { TransactionLink as DbTransactionLink, User as DbUser, UserContact as DbUserContact, + dbFindProjectBrandingByAlias, + dbFindProjectSpaceId, findUserByIdentifier, getHomeCommunity, - ProjectBranding, + ProjectBrandingSelect, UserLoggingView, } from 'database' import { GraphQLResolveInfo } from 'graphql' @@ -202,7 +204,7 @@ export class UserResolver { // request to humhub and klicktipp run in parallel let humhubUserPromise: Promise> | undefined - let projectBrandingPromise: Promise | undefined + let projectBrandingSpaceIdPromise: Promise | undefined const klicktippStatePromise = getKlicktippState(dbUser.emailContact.email) if (CONFIG.HUMHUB_ACTIVE && dbUser.humhubAllowed) { const getHumhubUser = new PostUser(dbUser) @@ -211,10 +213,7 @@ export class UserResolver { ) } if (project) { - projectBrandingPromise = ProjectBranding.findOne({ - where: { alias: project }, - select: { spaceId: true }, - }) + projectBrandingSpaceIdPromise = dbFindProjectSpaceId(project) } if ( @@ -244,19 +243,15 @@ export class UserResolver { }) await EVENT_USER_LOGIN(dbUser) - const projectBranding = await projectBrandingPromise - logger.debug('project branding: ', projectBranding?.id) + const projectBrandingSpaceId = await projectBrandingSpaceIdPromise + logger.debug('project branding: ', projectBrandingSpaceId) // load humhub state if (humhubUserPromise) { try { const result = await humhubUserPromise user.humhubAllowed = result?.result?.account.status === 1 if (user.humhubAllowed && result?.result?.account?.username) { - let spaceId = null - if (projectBranding) { - spaceId = projectBranding.spaceId - } - await syncHumhub(null, dbUser, result.result.account.username, spaceId) + await syncHumhub(null, dbUser, result.result.account.username, projectBrandingSpaceId) } } catch (e) { logger.error("couldn't reach out to humhub, disable for now", e) @@ -359,12 +354,9 @@ export class UserResolver { return user } } - let projectBrandingPromise: Promise | undefined + let projectBrandingPromise: Promise | undefined if (project) { - projectBrandingPromise = ProjectBranding.findOne({ - where: { alias: project }, - select: { logoUrl: true, spaceId: true }, - }) + projectBrandingPromise = dbFindProjectBrandingByAlias(project) } const gradidoID = await newGradidoID(logger) @@ -423,7 +415,7 @@ export class UserResolver { const queryRunner = db.getDataSource().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('REPEATABLE READ') - let projectBranding: ProjectBranding | null | undefined + let projectBranding: ProjectBrandingSelect | undefined try { dbUser = await queryRunner.manager.save(dbUser).catch((error) => { throw new LogError('Error while saving dbUser', error) @@ -924,7 +916,9 @@ export class UserResolver { } // should rarely happen, so we don't optimize for parallel processing if (!dbUser.humhubAllowed && project) { - await ProjectBranding.findOneOrFail({ where: { alias: project } }) + if (!(await dbFindProjectBrandingByAlias(project))) { + throw new LogError(`project branding with alias: ${project} not found`) + } dbUser.humhubAllowed = true await dbUser.save() } diff --git a/backend/src/graphql/resolver/util/syncHumhub.ts b/backend/src/graphql/resolver/util/syncHumhub.ts index 7fbca18ce..9f1556ce5 100644 --- a/backend/src/graphql/resolver/util/syncHumhub.ts +++ b/backend/src/graphql/resolver/util/syncHumhub.ts @@ -22,7 +22,7 @@ export async function syncHumhub( updateUserInfosArg: UpdateUserInfosArgs | null, user: User, oldHumhubUsername: string, - spaceId?: number | null, + spaceId?: number | null | undefined, ): Promise { const logger = createLogger() logger.addContext('user', user.id) diff --git a/bun.lock b/bun.lock index 9a4a4c2bb..984f39857 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "gradido", diff --git a/database/migration/migrations/0097-project_branding_add_unique_index_alias.ts b/database/migration/migrations/0097-project_branding_add_unique_index_alias.ts new file mode 100644 index 000000000..038f97f8a --- /dev/null +++ b/database/migration/migrations/0097-project_branding_add_unique_index_alias.ts @@ -0,0 +1,9 @@ +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE project_brandings ADD UNIQUE INDEX project_brandings_alias_unique (alias);`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE project_brandings DROP INDEX project_brandings_alias_unique;`) +} diff --git a/database/src/entity/ProjectBranding.ts b/database/src/entity/ProjectBranding.ts deleted file mode 100644 index f836f5824..000000000 --- a/database/src/entity/ProjectBranding.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm' - -@Entity('project_brandings') -export class ProjectBranding extends BaseEntity { - @PrimaryGeneratedColumn('increment', { unsigned: true }) - id: number - - @Column({ name: 'name', type: 'varchar', length: 255 }) - name: string - - @Column({ name: 'alias', type: 'varchar', length: 32 }) - alias: string - - @Column({ name: 'description', type: 'text', nullable: true, default: null }) - description: string | null - - @Column({ name: 'space_id', type: 'int', unsigned: true, nullable: true, default: null }) - spaceId: number | null - - @Column({ name: 'space_url', type: 'varchar', length: 255, nullable: true, default: null }) - spaceUrl: string | null - - @Column({ name: 'new_user_to_space', type: 'tinyint', width: 1, default: 0 }) - newUserToSpace: boolean - - @Column({ name: 'logo_url', type: 'varchar', length: 255, nullable: true, default: null }) - logoUrl: string | null -} diff --git a/database/src/entity/index.ts b/database/src/entity/index.ts index d2534af86..c8d8782aa 100644 --- a/database/src/entity/index.ts +++ b/database/src/entity/index.ts @@ -9,7 +9,6 @@ import { FederatedCommunity } from './FederatedCommunity' import { LoginElopageBuys } from './LoginElopageBuys' import { Migration } from './Migration' import { PendingTransaction } from './PendingTransaction' -import { ProjectBranding } from './ProjectBranding' import { Transaction } from './Transaction' import { TransactionLink } from './TransactionLink' import { User } from './User' @@ -27,7 +26,6 @@ export { FederatedCommunity, LoginElopageBuys, Migration, - ProjectBranding, PendingTransaction, Transaction, TransactionLink, @@ -47,7 +45,6 @@ export const entities = [ FederatedCommunity, LoginElopageBuys, Migration, - ProjectBranding, PendingTransaction, Transaction, TransactionLink, diff --git a/database/src/queries/index.ts b/database/src/queries/index.ts index 0a7eb9ac3..93f0e95f1 100644 --- a/database/src/queries/index.ts +++ b/database/src/queries/index.ts @@ -5,6 +5,7 @@ export * from './communityHandshakes' export * from './events' export * from './openaiThreads' export * from './pendingTransactions' +export * from './projectBranding' export * from './transactionLinks' export * from './transactions' export * from './user' diff --git a/database/src/queries/projectBranding.ts b/database/src/queries/projectBranding.ts new file mode 100644 index 000000000..4ea33a84f --- /dev/null +++ b/database/src/queries/projectBranding.ts @@ -0,0 +1,116 @@ +import { eq, sql } from 'drizzle-orm' +import { ProjectBranding } from 'shared/src/schema/projectBranding.schema' +import { drizzleDb } from '../AppDatabase' +import { + ProjectBrandingInsert, + ProjectBrandingSelect, + projectBrandingsTable, +} from '../schemas/drizzle.schema' + +/** + * Needed because of TypeScript 4, in TypeScript 5 we can use valibot and auto deduct a valibot schema from drizzle db schema + * Converts a ProjectBranding object to a ProjectBrandingInsert object to be used in database operations. + * @param projectBranding - The ProjectBranding object to convert. + * @returns The converted ProjectBrandingInsert object. + */ +function toDbInsert(projectBranding: ProjectBranding): ProjectBrandingInsert { + return { + // Omit ID when inserting (autoincrement) or set it if it exists + id: projectBranding.id ?? undefined, + name: projectBranding.name, + alias: projectBranding.alias, + // Set null in DB if undefined/null + description: projectBranding.description ?? null, + spaceId: projectBranding.spaceId ?? null, + spaceUrl: projectBranding.spaceUrl ?? null, + // Convert boolean to tinyint (1/0) + newUserToSpace: projectBranding.newUserToSpace ? 1 : 0, + logoUrl: projectBranding.logoUrl ?? null, + } +} + +export async function dbUpsertProjectBranding( + projectBranding: ProjectBranding, +): Promise { + if (projectBranding.id) { + await drizzleDb() + .update(projectBrandingsTable) + .set(toDbInsert(projectBranding)) + .where(eq(projectBrandingsTable.id, projectBranding.id)) + + return projectBranding + } else { + const drizzleProjectBranding = toDbInsert(projectBranding) + const result = await drizzleDb().insert(projectBrandingsTable).values(drizzleProjectBranding) + + projectBranding.id = result[0].insertId + return projectBranding + } +} + +export async function dbFindProjectSpaceUrl(alias: string): Promise { + const result = await drizzleDb() + .select({ spaceUrl: projectBrandingsTable.spaceUrl }) + .from(projectBrandingsTable) + .where(eq(projectBrandingsTable.alias, alias)) + .limit(1) + return result.at(0)?.spaceUrl +} + +export async function dbFindProjectSpaceId(alias: string): Promise { + const result = await drizzleDb() + .select({ spaceId: projectBrandingsTable.spaceId }) + .from(projectBrandingsTable) + .where(eq(projectBrandingsTable.alias, alias)) + .limit(1) + return result.at(0)?.spaceId +} +/** + * + * @param alias throw if project not found + * @returns logoUrl if project has logoUrl, else return null + */ + +export async function dbGetProjectLogoURL(alias: string): Promise { + const result = await drizzleDb() + .select({ logoUrl: projectBrandingsTable.logoUrl }) + .from(projectBrandingsTable) + .where(eq(projectBrandingsTable.alias, alias)) + .limit(1) + const firstEntry = result.at(0) + if (!firstEntry) { + throw new Error(`Project Branding with alias: ${alias} not found`) + } + return firstEntry.logoUrl +} + +export async function dbFindAllProjectBrandings(): Promise { + const result = await drizzleDb().select().from(projectBrandingsTable) + return result +} + +export async function dbFindProjectBrandingById( + id: number, +): Promise { + const result = await drizzleDb() + .select() + .from(projectBrandingsTable) + .where(eq(projectBrandingsTable.id, id)) + .limit(1) + return result.at(0) +} + +export async function dbFindProjectBrandingByAlias( + alias: string, +): Promise { + const result = await drizzleDb() + .select() + .from(projectBrandingsTable) + .where(eq(projectBrandingsTable.alias, alias)) + .limit(1) + return result.at(0) +} + +export async function dbDeleteProjectBranding(id: number): Promise { + await drizzleDb().delete(projectBrandingsTable).where(eq(projectBrandingsTable.id, id)) +} diff --git a/database/src/schemas/drizzle.schema.ts b/database/src/schemas/drizzle.schema.ts index 3dabc98e8..734eda8f1 100644 --- a/database/src/schemas/drizzle.schema.ts +++ b/database/src/schemas/drizzle.schema.ts @@ -1,4 +1,14 @@ -import { int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core' +import { sql } from 'drizzle-orm' +import { + int, + mysqlTable, + text, + timestamp, + tinyint, + uniqueIndex, + varchar, +} from 'drizzle-orm/mysql-core' +import { z } from 'zod' export const openaiThreadsTable = mysqlTable('openai_threads', { id: varchar({ length: 128 }).notNull(), @@ -6,3 +16,21 @@ export const openaiThreadsTable = mysqlTable('openai_threads', { updatedAt: timestamp({ mode: 'date' }).defaultNow().onUpdateNow().notNull(), userId: int('user_id').notNull(), }) + +export const projectBrandingsTable = mysqlTable( + 'project_brandings', + { + id: int().autoincrement().notNull(), + name: varchar({ length: 255 }).notNull(), + alias: varchar({ length: 32 }).notNull(), + description: text().default(sql`NULL`), + spaceId: int('space_id').default(sql`NULL`), + spaceUrl: varchar('space_url', { length: 255 }).default(sql`NULL`), + newUserToSpace: tinyint('new_user_to_space').default(0).notNull(), + logoUrl: varchar('logo_url', { length: 255 }).default(sql`NULL`), + }, + (table) => [uniqueIndex('project_brandings_alias_unique').on(table.alias)], +) + +export type ProjectBrandingSelect = typeof projectBrandingsTable.$inferSelect +export type ProjectBrandingInsert = typeof projectBrandingsTable.$inferInsert diff --git a/shared/src/schema/index.ts b/shared/src/schema/index.ts index 8d1f75b22..bf94f5e43 100644 --- a/shared/src/schema/index.ts +++ b/shared/src/schema/index.ts @@ -1,3 +1,4 @@ export * from './base.schema' export * from './community.schema' +export * from './projectBranding.schema' export * from './user.schema' diff --git a/shared/src/schema/projectBranding.schema.ts b/shared/src/schema/projectBranding.schema.ts new file mode 100644 index 000000000..8879ce327 --- /dev/null +++ b/shared/src/schema/projectBranding.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +// will be auto-generated in future directly from Drizzle table schema, this need TypeScript 5 +export const projectBrandingSchema = z.object({ + id: z.number().optional().nullable(), + name: z.string(), + alias: z.string().max(32), + description: z.string().optional().nullable(), + spaceId: z.number().optional().nullable(), + spaceUrl: z.string().url().optional().nullable(), + newUserToSpace: z.boolean(), + logoUrl: z.string().url().optional().nullable(), +}) + +export type ProjectBranding = z.infer