Merge branch 'master' into migrate_with_zig

This commit is contained in:
einhornimmond 2026-03-10 11:05:40 +01:00
commit 243475008f
13 changed files with 208 additions and 86 deletions

View File

@ -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' })

View File

@ -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)
}

View File

@ -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<ProjectBranding[]> {
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<ProjectBranding> {
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<string | null> {
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<ProjectBranding | null> {
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<boolean> {
try {
await DbProjectBranding.delete({ id })
await dbDeleteProjectBranding(id)
return true
} catch (err) {
logger.error(err)

View File

@ -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<IRestResponse<GetUser>> | undefined
let projectBrandingPromise: Promise<ProjectBranding | null> | undefined
let projectBrandingSpaceIdPromise: Promise<number | null | undefined> | 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<ProjectBranding | null> | undefined
let projectBrandingPromise: Promise<ProjectBrandingSelect | undefined> | 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()
}

View File

@ -22,7 +22,7 @@ export async function syncHumhub(
updateUserInfosArg: UpdateUserInfosArgs | null,
user: User,
oldHumhubUsername: string,
spaceId?: number | null,
spaceId?: number | null | undefined,
): Promise<GetUser | null | undefined> {
const logger = createLogger()
logger.addContext('user', user.id)

View File

@ -0,0 +1,9 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE project_brandings ADD UNIQUE INDEX project_brandings_alias_unique (alias);`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE project_brandings DROP INDEX project_brandings_alias_unique;`)
}

View File

@ -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
}

View File

@ -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,

View File

@ -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'

View File

@ -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<ProjectBranding> {
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<string | null | undefined> {
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<number | null | undefined> {
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<string | null> {
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<ProjectBrandingSelect[]> {
const result = await drizzleDb().select().from(projectBrandingsTable)
return result
}
export async function dbFindProjectBrandingById(
id: number,
): Promise<ProjectBrandingSelect | undefined> {
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<ProjectBrandingSelect | undefined> {
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<void> {
await drizzleDb().delete(projectBrandingsTable).where(eq(projectBrandingsTable.id, id))
}

View File

@ -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

View File

@ -1,3 +1,4 @@
export * from './base.schema'
export * from './community.schema'
export * from './projectBranding.schema'
export * from './user.schema'

View File

@ -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<typeof projectBrandingSchema>