fix: use admin R2 avatars for operator activity..
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-14 11:51:43 +09:00
parent cd4bdbecd7
commit e811208773
14 changed files with 96 additions and 142 deletions
+12 -8
View File
@@ -46,16 +46,20 @@ model LauncherTerm {
} }
model LauncherManualDocument { model LauncherManualDocument {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
appKey String @map("app_key") appKey String @map("app_key")
title String title String
body String body String
isPublished Boolean @default(true) @map("is_published") isPublished Boolean @default(true) @map("is_published")
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdById String? @map("created_by_id") @db.Uuid
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) createdByName String? @map("created_by_name")
createdByEmail String? @map("created_by_email")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([appKey, isPublished, sortOrder]) @@index([appKey, isPublished, sortOrder])
@@index([createdByEmail])
@@map("launcher_manual_documents") @@map("launcher_manual_documents")
} }
@@ -0,0 +1,7 @@
ALTER TABLE "launcher_manual_documents"
ADD COLUMN IF NOT EXISTS "created_by_id" UUID,
ADD COLUMN IF NOT EXISTS "created_by_name" TEXT,
ADD COLUMN IF NOT EXISTS "created_by_email" TEXT;
CREATE INDEX IF NOT EXISTS "launcher_manual_documents_created_by_email_idx"
ON "launcher_manual_documents"("created_by_email");
+9
View File
@@ -0,0 +1,9 @@
import { ConfigService } from '@nestjs/config';
export function getAdminAvatarUrl(adminId: string | null | undefined, configService: ConfigService) {
const publicBaseUrl = configService.get<string>('R2_PUBLIC_BASE_URL')?.replace(/\/+$/, '');
if (!adminId || !publicBaseUrl) return null;
const prefix = configService.get<string>('R2_PUBLIC_FILE_PREFIX', 'studio-files/public');
return `${publicBaseUrl}/${prefix.replace(/^\/+|\/+$/g, '')}/admins/${adminId}/avatar`;
}
+1 -7
View File
@@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
import { GiteaService } from '../gitea/gitea.service'; import { GiteaService } from '../gitea/gitea.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { getAdminAvatarUrl } from './admin-avatar';
import { AdminRole } from './admin-role'; import { AdminRole } from './admin-role';
import { import {
CreateAdminSshKeyDto, CreateAdminSshKeyDto,
@@ -334,13 +335,6 @@ export class AdminsService {
} }
} }
function getAdminAvatarUrl(adminId: string, configService: ConfigService) {
const publicBaseUrl = configService.get<string>('R2_PUBLIC_BASE_URL')?.replace(/\/+$/, '');
if (!publicBaseUrl) return null;
const prefix = configService.get<string>('R2_PUBLIC_FILE_PREFIX', 'studio-files/public');
return `${publicBaseUrl}/${prefix.replace(/^\/+|\/+$/g, '')}/admins/${adminId}/avatar`;
}
const adminSelect = { const adminSelect = {
id: true, id: true,
email: true, email: true,
+1 -2
View File
@@ -2,13 +2,12 @@ import { Module } from '@nestjs/common';
import { LauncherModule } from '../launcher/launcher.module'; import { LauncherModule } from '../launcher/launcher.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { AppsController } from './apps.controller'; import { AppsController } from './apps.controller';
import { AppsService } from './apps.service'; import { AppsService } from './apps.service';
import { BuildRunnerService } from './build-runner.service'; import { BuildRunnerService } from './build-runner.service';
@Module({ @Module({
imports: [PrismaModule, LauncherModule, AuthPrismaModule], imports: [PrismaModule, LauncherModule],
controllers: [AppsController], controllers: [AppsController],
providers: [AppsService, BuildRunnerService], providers: [AppsService, BuildRunnerService],
}) })
+6 -28
View File
@@ -8,10 +8,9 @@ import * as path from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { LinkComponentSourceDto } from '../gitea/dto'; import { LinkComponentSourceDto } from '../gitea/dto';
import { getAdminAvatarUrl } from '../admins/admin-avatar';
import { LauncherService } from '../launcher/launcher.service'; import { LauncherService } from '../launcher/launcher.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
import { getUserAvatarUrl } from '../users/users.service';
import { BuildRunnerService } from './build-runner.service'; import { BuildRunnerService } from './build-runner.service';
import { import {
CreateAppComponentDto, CreateAppComponentDto,
@@ -46,7 +45,6 @@ export class AppsService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly buildRunnerService: BuildRunnerService, private readonly buildRunnerService: BuildRunnerService,
private readonly launcherService: LauncherService, private readonly launcherService: LauncherService,
private readonly authPrisma: AuthPrismaService,
) {} ) {}
private requesterData(requester?: AdminRequester) { private requesterData(requester?: AdminRequester) {
@@ -59,31 +57,11 @@ export class AppsService {
: {}; : {};
} }
private async withRequesterAvatars<T extends { requestedByEmail?: string | null }>(items: T[]) { private withRequesterAvatars<T extends { requestedById?: string | null }>(items: T[]) {
const emails = [...new Set(items.map((item) => item.requestedByEmail).filter((email): email is string => Boolean(email)))]; return items.map((item) => ({
if (emails.length === 0) return items.map((item) => ({ ...item, requestedByAvatarUrl: null })); ...item,
requestedByAvatarUrl: getAdminAvatarUrl(item.requestedById, this.configService),
const users = await this.authPrisma.user.findMany({ }));
where: { email: { in: emails } },
select: {
id: true,
email: true,
profileImageMime: true,
},
});
const userByEmail = new Map(users.map((user) => [user.email, user]));
const authBaseUrl = this.configService.get<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
?? 'https://auth.autonomystudio.net';
return items.map((item) => {
const user = item.requestedByEmail ? userByEmail.get(item.requestedByEmail) : null;
return {
...item,
requestedByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
};
});
} }
async listApps() { async listApps() {
+1 -2
View File
@@ -2,12 +2,11 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { LauncherController } from './launcher.controller'; import { LauncherController } from './launcher.controller';
import { LauncherService } from './launcher.service'; import { LauncherService } from './launcher.service';
@Module({ @Module({
imports: [PrismaModule, LauncherPrismaModule, AuthPrismaModule], imports: [PrismaModule, LauncherPrismaModule],
controllers: [LauncherController], controllers: [LauncherController],
providers: [LauncherService], providers: [LauncherService],
exports: [LauncherService], exports: [LauncherService],
+6 -28
View File
@@ -10,8 +10,7 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { getAdminAvatarUrl } from '../admins/admin-avatar';
import { getUserAvatarUrl } from '../users/users.service';
import { import {
CreateLauncherAppLinkDto, CreateLauncherAppLinkDto,
CreateLauncherBuildJobDto, CreateLauncherBuildJobDto,
@@ -63,7 +62,6 @@ export class LauncherService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly launcherPrisma: LauncherPrismaService, private readonly launcherPrisma: LauncherPrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly authPrisma: AuthPrismaService,
) {} ) {}
async listVersions() { async listVersions() {
@@ -453,31 +451,11 @@ export class LauncherService {
}); });
} }
private async withLauncherBuildRequesters<T extends { requestedByEmail?: string | null }>(jobs: T[]) { private withLauncherBuildRequesters<T extends { requestedById?: string | null }>(jobs: T[]) {
const emails = [...new Set(jobs.map((job) => job.requestedByEmail).filter((email): email is string => Boolean(email)))]; return jobs.map((job) => ({
if (emails.length === 0) return jobs.map((job) => ({ ...job, requestedByAvatarUrl: null })); ...job,
requestedByAvatarUrl: getAdminAvatarUrl(job.requestedById, this.configService),
const users = await this.authPrisma.user.findMany({ }));
where: { email: { in: emails } },
select: {
id: true,
email: true,
profileImageMime: true,
},
});
const userByEmail = new Map(users.map((user) => [user.email, user]));
const authBaseUrl = this.configService.get<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
?? 'https://auth.autonomystudio.net';
return jobs.map((job) => {
const user = job.requestedByEmail ? userByEmail.get(job.requestedByEmail) : null;
return {
...job,
requestedByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
};
});
} }
+6 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from '@nestjs/common';
import { Public } from '../admin-auth/decorators/admin-auth.decorators'; import { Public } from '../admin-auth/decorators/admin-auth.decorators';
import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto'; import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto';
@@ -20,8 +20,11 @@ export class ManualsController {
} }
@Post() @Post()
create(@Body() body: CreateManualDocumentDto) { create(
return this.manualsService.create(body); @Body() body: CreateManualDocumentDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.manualsService.create(body, request.admin);
} }
@Patch(':id') @Patch(':id')
+33 -4
View File
@@ -1,12 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/launcher-client'; import { Prisma } from '@prisma/launcher-client';
import { getAdminAvatarUrl } from '../admins/admin-avatar';
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto'; import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto';
@Injectable() @Injectable()
export class ManualsService { export class ManualsService {
constructor(private readonly launcherPrisma: LauncherPrismaService) {} constructor(
private readonly configService: ConfigService,
private readonly launcherPrisma: LauncherPrismaService,
) {}
private creatorData(requester?: AdminRequester) {
return requester
? {
createdById: requester.id,
createdByName: requester.name,
createdByEmail: requester.email,
}
: {};
}
private withCreatorAvatars<T extends { createdById?: string | null }>(items: T[]) {
return items.map((item) => ({
...item,
createdByAvatarUrl: getAdminAvatarUrl(item.createdById, this.configService),
}));
}
async list(query: ListManualDocumentsQuery) { async list(query: ListManualDocumentsQuery) {
const page = toPositiveInt(query.page, 1); const page = toPositiveInt(query.page, 1);
@@ -35,10 +57,10 @@ export class ManualsService {
]); ]);
return { return {
documents: documents.map((document, index) => ({ documents: await this.withCreatorAvatars(documents.map((document, index) => ({
...document, ...document,
no: total - ((page - 1) * limit + index), no: total - ((page - 1) * limit + index),
})), }))),
total, total,
page, page,
limit, limit,
@@ -61,7 +83,7 @@ export class ManualsService {
return { documents }; return { documents };
} }
async create(body: CreateManualDocumentDto) { async create(body: CreateManualDocumentDto, requester?: AdminRequester) {
const document = await this.launcherPrisma.launcherManualDocument.create({ const document = await this.launcherPrisma.launcherManualDocument.create({
data: { data: {
appKey: body.appKey.trim(), appKey: body.appKey.trim(),
@@ -69,6 +91,7 @@ export class ManualsService {
body: body.body.trim(), body: body.body.trim(),
isPublished: body.isPublished ?? true, isPublished: body.isPublished ?? true,
sortOrder: body.sortOrder ?? 0, sortOrder: body.sortOrder ?? 0,
...this.creatorData(requester),
}, },
}); });
@@ -105,6 +128,12 @@ export class ManualsService {
} }
type AdminRequester = {
id: string;
name: string;
email: string;
};
function toPositiveInt(value: string | undefined, fallback: number) { function toPositiveInt(value: string | undefined, fallback: number) {
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
+1 -2
View File
@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
import { NoticesController } from './notices.controller'; import { NoticesController } from './notices.controller';
import { NoticesService } from './notices.service'; import { NoticesService } from './notices.service';
@Module({ @Module({
imports: [AuthPrismaModule, LauncherPrismaModule], imports: [LauncherPrismaModule],
controllers: [NoticesController], controllers: [NoticesController],
providers: [NoticesService], providers: [NoticesService],
}) })
+6 -28
View File
@@ -2,9 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/launcher-client'; import { Prisma } from '@prisma/launcher-client';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { getAdminAvatarUrl } from '../admins/admin-avatar';
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
import { getUserAvatarUrl } from '../users/users.service';
import { import {
CreateLauncherNoticeDto, CreateLauncherNoticeDto,
ListLauncherNoticesQuery, ListLauncherNoticesQuery,
@@ -20,7 +19,6 @@ type AdminRequester = {
@Injectable() @Injectable()
export class NoticesService { export class NoticesService {
constructor( constructor(
private readonly authPrisma: AuthPrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly launcherPrisma: LauncherPrismaService, private readonly launcherPrisma: LauncherPrismaService,
) {} ) {}
@@ -35,31 +33,11 @@ export class NoticesService {
: {}; : {};
} }
private async withCreatorAvatars<T extends { createdByEmail?: string | null }>(items: T[]) { private withCreatorAvatars<T extends { createdById?: string | null }>(items: T[]) {
const emails = [...new Set(items.map((item) => item.createdByEmail).filter((email): email is string => Boolean(email)))]; return items.map((item) => ({
if (emails.length === 0) return items.map((item) => ({ ...item, createdByAvatarUrl: null })); ...item,
createdByAvatarUrl: getAdminAvatarUrl(item.createdById, this.configService),
const users = await this.authPrisma.user.findMany({ }));
where: { email: { in: emails } },
select: {
id: true,
email: true,
profileImageMime: true,
},
});
const userByEmail = new Map(users.map((user) => [user.email, user]));
const authBaseUrl = this.configService.get<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
?? 'https://auth.autonomystudio.net';
return items.map((item) => {
const user = item.createdByEmail ? userByEmail.get(item.createdByEmail) : null;
return {
...item,
createdByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
};
});
} }
async list(query: ListLauncherNoticesQuery) { async list(query: ListLauncherNoticesQuery) {
+1 -2
View File
@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
import { TermsController } from './terms.controller'; import { TermsController } from './terms.controller';
import { TermsService } from './terms.service'; import { TermsService } from './terms.service';
@Module({ @Module({
imports: [AuthPrismaModule, LauncherPrismaModule], imports: [LauncherPrismaModule],
controllers: [TermsController], controllers: [TermsController],
providers: [TermsService], providers: [TermsService],
}) })
+6 -28
View File
@@ -2,9 +2,8 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/launcher-client'; import { Prisma } from '@prisma/launcher-client';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { getAdminAvatarUrl } from '../admins/admin-avatar';
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
import { getUserAvatarUrl } from '../users/users.service';
import { CreateTermDto, ListTermsQuery, UpdateTermDto } from './dto'; import { CreateTermDto, ListTermsQuery, UpdateTermDto } from './dto';
type AdminRequester = { type AdminRequester = {
@@ -16,7 +15,6 @@ type AdminRequester = {
@Injectable() @Injectable()
export class TermsService { export class TermsService {
constructor( constructor(
private readonly authPrisma: AuthPrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly launcherPrisma: LauncherPrismaService, private readonly launcherPrisma: LauncherPrismaService,
) {} ) {}
@@ -115,31 +113,11 @@ export class TermsService {
: {}; : {};
} }
private async withCreatorAvatars<T extends { createdByEmail?: string | null }>(items: T[]) { private withCreatorAvatars<T extends { createdById?: string | null }>(items: T[]) {
const emails = [...new Set(items.map((item) => item.createdByEmail).filter((email): email is string => Boolean(email)))]; return items.map((item) => ({
if (emails.length === 0) return items.map((item) => ({ ...item, createdByAvatarUrl: null })); ...item,
createdByAvatarUrl: getAdminAvatarUrl(item.createdById, this.configService),
const users = await this.authPrisma.user.findMany({ }));
where: { email: { in: emails } },
select: {
id: true,
email: true,
profileImageMime: true,
},
});
const userByEmail = new Map(users.map((user) => [user.email, user]));
const authBaseUrl = this.configService.get<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
?? 'https://auth.autonomystudio.net';
return items.map((item) => {
const user = item.createdByEmail ? userByEmail.get(item.createdByEmail) : null;
return {
...item,
createdByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
};
});
} }
private async ensureExists(id: string) { private async ensureExists(id: string) {