From ae266790c85879fa75c2c9ff86706be7d46870a5 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 12:09:51 +0200 Subject: [PATCH 001/103] feat: port metrics to miiverse-api + add API to fetch metrics --- apps/miiverse-api/src/metrics.ts | 173 ++++++++++++++++++ apps/miiverse-api/src/server.entry.ts | 34 +--- .../internal/contract/admin/adminStats.ts | 14 ++ .../src/services/internal/index.ts | 2 + .../internal/routes/admin/adminStats.ts | 26 +++ 5 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 apps/miiverse-api/src/metrics.ts create mode 100644 apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts create mode 100644 apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts diff --git a/apps/miiverse-api/src/metrics.ts b/apps/miiverse-api/src/metrics.ts new file mode 100644 index 00000000..221a5868 --- /dev/null +++ b/apps/miiverse-api/src/metrics.ts @@ -0,0 +1,173 @@ +import { Gauge } from 'prom-client'; +import expressMetrics from 'express-prom-bundle'; +import express from 'express'; +import { logger } from '@/logger'; +import { config } from '@/config'; +import { Settings } from '@/models/settings'; +import { Post } from '@/models/post'; +import type { Express, NextFunction, Request, Response } from 'express'; + +// This file contains juxtaposition_ui prefixed metrics. +// This is for backwards compatibility from when they were in the juxtaposition-ui project + +export const onlineNowGauge = new Gauge({ + name: 'juxtaposition_online_users_now', + help: 'Users active in the last 10 minutes', + async collect(): Promise { + const onlineRangeMs = 10 * 60 * 1000; // 10 minutes + const cutoff = new Date(Date.now() - onlineRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const activeMonthlyGauge = new Gauge({ + name: 'juxtaposition_active_users_monthly', + help: 'Users active in the last 30 days', + async collect(): Promise { + const monthlyRangeMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - monthlyRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const activeYearlyGauge = new Gauge({ + name: 'juxtaposition_active_users_yearly', + help: 'Users active in the last 365 days', + async collect(): Promise { + const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - yearlyRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const totalUsersGauge = new Gauge({ + name: 'juxtaposition_user_count_total', + help: 'Total number of registered users', + async collect(): Promise { + const count = await Settings.estimatedDocumentCount(); + this.set(count); + } +}); + +export async function getUserMetrics(): Promise<{ totalUsers: number; currentOnlineUsers: number }> { + // This assumes that both gauges have no labels + return { + totalUsers: (await totalUsersGauge.get()).values[0].value, + currentOnlineUsers: (await onlineNowGauge.get()).values[0].value + }; +} + +export const dailyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_daily', + help: 'New posts in the last 24 hours', + async collect(): Promise { + const dailyRangeMs = 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - dailyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const monthlyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_monthly', + help: 'New posts in the last 30 days', + async collect(): Promise { + const monthyRangeMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - monthyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const yearlyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_yearly', + help: 'New posts in the last 365 days', + async collect(): Promise { + const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - yearlyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const totalPostsGauge = new Gauge({ + name: 'juxtaposition_post_count_total', + help: 'Total number of posts', + async collect(): Promise { + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export async function getPostMetrics(): Promise<{ totalPosts: number; dailyPosts: number }> { + // This assumes that both gauges have no labels + return { + totalPosts: (await totalPostsGauge.get()).values[0].value, + dailyPosts: (await dailyPostGauge.get()).values[0].value + }; +} + +export function registerMetrics(app: Express): Express { + const metrics = express(); + + if (config.metrics.enabled) { + logger.info('Setting up metrics'); + app.use(expressMetrics({ + // Include full express and nodejs metrics + includeMethod: true, + includePath: true, + urlValueParser: { + minBase64Length: 15 + }, + promClient: { + collectDefaultMetrics: {} + }, + + // Keep metrics on a different app (so they aren't exposed) + autoregister: false, + metricsApp: metrics + })); + } + + metrics.use((error: Error, req: Request, res: Response, _next: NextFunction) => { + logger.error(error, 'Request failed!'); + res.sendStatus(500); + }); + + return metrics; +} + +export function listenMetrics(metricsApp: Express): void { + if (!config.metrics.enabled) { + return; + } + + const port = config.metrics.port; + metricsApp.listen(port, '0.0.0.0', () => { + logger.success(`Metrics server started on port ${port}`); + }); +} diff --git a/apps/miiverse-api/src/server.entry.ts b/apps/miiverse-api/src/server.entry.ts index a74096c2..7621e020 100644 --- a/apps/miiverse-api/src/server.entry.ts +++ b/apps/miiverse-api/src/server.entry.ts @@ -1,6 +1,5 @@ import '@/extend-zod'; // Needs to be the first import import express from 'express'; -import expressMetrics from 'express-prom-bundle'; import { connect as connectDatabase } from '@/database'; import { logger } from '@/logger'; import { loggerHttp } from '@/loggerHttp'; @@ -14,30 +13,12 @@ import { initImageProcessing } from '@/images'; import { ApiErrorCode, badRequest, serverError } from '@/errors'; import { connectGrpc } from '@/grpc'; import { setupS3 } from '@/s3'; +import { listenMetrics, registerMetrics } from '@/metrics'; -const { http: { port }, metrics: { enabled: metricsEnabled, port: metricsPort } } = config; -const metrics = express(); const app = express(); // Metrics has to happen first so we can measure the other middleware -if (metricsEnabled) { - logger.info('Setting up metrics'); - app.use(expressMetrics({ - // Include full express and nodejs metrics - includeMethod: true, - includePath: true, - urlValueParser: { - minBase64Length: 15 - }, - promClient: { - collectDefaultMetrics: {} - }, - - // Keep metrics on a different app (so they aren't exposed) - autoregister: false, - metricsApp: metrics - })); -} +const metricsApp = registerMetrics(app); app.set('etag', false); app.set('trust proxy', config.http.trustProxy); @@ -86,17 +67,12 @@ async function main(): Promise { await connectDatabase(); await initImageProcessing(); - app.listen(port, '0.0.0.0', () => { - logger.info(`Server started on port ${port}`); + app.listen(config.http.port, '0.0.0.0', () => { + logger.info(`Server started on port ${config.http.port}`); }); + listenMetrics(metricsApp); await setupGrpc(); - - if (metricsEnabled) { - metrics.listen(metricsPort, () => { - logger.info(`Metrics server started on port ${metricsPort}`); - }); - } } process.title = 'Pretendo - Miiverse'; diff --git a/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts b/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts new file mode 100644 index 00000000..5776eef2 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const adminStatsSchema = z.object({ + dailyPosts: z.number(), + totalPosts: z.number(), + onlineUsers: z.number(), + totalUsers: z.number() +}).openapi('AdminStats'); + +export type AdminStatsDto = z.infer; + +export function mapAdminStats(stats: AdminStatsDto): AdminStatsDto { + return stats; +} diff --git a/apps/miiverse-api/src/services/internal/index.ts b/apps/miiverse-api/src/services/internal/index.ts index d274a8fb..37eabf8a 100644 --- a/apps/miiverse-api/src/services/internal/index.ts +++ b/apps/miiverse-api/src/services/internal/index.ts @@ -11,6 +11,7 @@ import { adminReportsRouter } from '@/services/internal/routes/admin/adminReport import { adminUsersRouter } from '@/services/internal/routes/admin/adminUsers'; import { notificationsRouter } from '@/services/internal/routes/notification'; import { adminAuditLogs } from '@/services/internal/routes/admin/adminAuditLogs'; +import { adminStatsRouter } from '@/services/internal/routes/admin/adminStats'; export const internalApiRouter = express.Router(); internalApiRouter.use('/api/v1', postsRouter.toRouter()); @@ -26,3 +27,4 @@ internalApiRouter.use('/api/v1', adminCommunitiesRouter.toRouter()); internalApiRouter.use('/api/v1', adminReportsRouter.toRouter()); internalApiRouter.use('/api/v1', adminUsersRouter.toRouter()); internalApiRouter.use('/api/v1', adminAuditLogs.toRouter()); +internalApiRouter.use('/api/v1', adminStatsRouter.toRouter()); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts new file mode 100644 index 00000000..d40427ff --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts @@ -0,0 +1,26 @@ +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { guards } from '@/services/internal/middleware/guards'; +import { adminStatsSchema, mapAdminStats } from '@/services/internal/contract/admin/adminStats'; +import { getPostMetrics, getUserMetrics } from '@/metrics'; + +export const adminStatsRouter = createInternalApiRouter(); + +adminStatsRouter.get({ + path: '/admin/stats', + name: 'admin.getStats', + guard: guards.moderator, + schema: { + response: adminStatsSchema + }, + async handler() { + const userMetrics = await getUserMetrics(); + const postMetrics = await getPostMetrics(); + + return mapAdminStats({ + dailyPosts: postMetrics.dailyPosts, + totalPosts: postMetrics.totalPosts, + onlineUsers: userMetrics.currentOnlineUsers, + totalUsers: userMetrics.totalUsers + }); + } +}); From ebf50cb1f6b0ec2cd292d26c11815813a43d6958 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 12:10:18 +0200 Subject: [PATCH 002/103] feat: remove duplicate metrics from juxtaposition-ui and use API to fetch metrics --- apps/juxtaposition-ui/src/metrics.ts | 124 ------------------ .../services/juxt-web/routes/admin/admin.tsx | 10 +- 2 files changed, 4 insertions(+), 130 deletions(-) diff --git a/apps/juxtaposition-ui/src/metrics.ts b/apps/juxtaposition-ui/src/metrics.ts index 2c7fa76f..0bb35a6e 100644 --- a/apps/juxtaposition-ui/src/metrics.ts +++ b/apps/juxtaposition-ui/src/metrics.ts @@ -1,133 +1,9 @@ -import { Gauge } from 'prom-client'; import expressMetrics from 'express-prom-bundle'; import express from 'express'; import { logger } from '@/logger'; import { config } from '@/config'; -import { SETTINGS } from '@/models/settings'; -import { POST } from '@/models/post'; import type { Express, NextFunction, Request, Response } from 'express'; -export const onlineNowGauge = new Gauge({ - name: 'juxtaposition_online_users_now', - help: 'Users active in the last 10 minutes', - async collect(): Promise { - const onlineRangeMs = 10 * 60 * 1000; // 10 minutes - const cutoff = new Date(Date.now() - onlineRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const activeMonthlyGauge = new Gauge({ - name: 'juxtaposition_active_users_monthly', - help: 'Users active in the last 30 days', - async collect(): Promise { - const monthlyRangeMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - monthlyRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const activeYearlyGauge = new Gauge({ - name: 'juxtaposition_active_users_yearly', - help: 'Users active in the last 365 days', - async collect(): Promise { - const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - yearlyRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const totalUsersGauge = new Gauge({ - name: 'juxtaposition_user_count_total', - help: 'Total number of registered users', - async collect(): Promise { - const count = await SETTINGS.estimatedDocumentCount(); - this.set(count); - } -}); - -export async function getUserMetrics(): Promise<{ totalUsers: number; currentOnlineUsers: number }> { - // This assumes that both gauges have no labels - return { - totalUsers: (await totalUsersGauge.get()).values[0].value, - currentOnlineUsers: (await onlineNowGauge.get()).values[0].value - }; -} - -export const dailyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_daily', - help: 'New posts in the last 24 hours', - async collect(): Promise { - const dailyRangeMs = 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - dailyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const monthlyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_monthly', - help: 'New posts in the last 30 days', - async collect(): Promise { - const monthyRangeMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - monthyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const yearlyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_yearly', - help: 'New posts in the last 365 days', - async collect(): Promise { - const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - yearlyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const totalPostsGauge = new Gauge({ - name: 'juxtaposition_post_count_total', - help: 'Total number of posts', - async collect(): Promise { - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export async function getPostMetrics(): Promise<{ totalPosts: number; dailyPosts: number }> { - // This assumes that both gauges have no labels - return { - totalPosts: (await totalPostsGauge.get()).values[0].value, - dailyPosts: (await dailyPostGauge.get()).values[0].value - }; -} - export function registerMetrics(app: Express): Express { const metrics = express(); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index 9b2255e8..f843b3fd 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -2,7 +2,6 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; import { getReasonMap, updateCommunityHashForAdminCommunity } from '@/util'; -import { getPostMetrics, getUserMetrics } from '@/metrics'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserListView } from '@/services/juxt-web/views/web/admin/userListView'; import { WebReportListView } from '@/services/juxt-web/views/web/admin/reportListView'; @@ -47,8 +46,7 @@ adminRouter.get('/accounts', async function (req, res) { offset }); - const userMetrics = await getUserMetrics(); - const postMetrics = await getPostMetrics(); + const { data: stats } = await req.api.admin.getStats(); res.jsxForDirectory({ web: ( @@ -57,9 +55,9 @@ adminRouter.get('/accounts', async function (req, res) { page={query.page} search={search} userCount={usersPage.total} - activeCount={userMetrics.currentOnlineUsers} - dailyPostCount={postMetrics.dailyPosts} - totalPostCount={postMetrics.totalPosts} + activeCount={stats.onlineUsers} + dailyPostCount={stats.dailyPosts} + totalPostCount={stats.totalPosts} /> ) }); From 862dac37aeaaee27bb68c0445e6b7d45fd6fee2a Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 12:10:32 +0200 Subject: [PATCH 003/103] feat: add missing indexes on miiverse-api models --- apps/miiverse-api/src/models/post.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/miiverse-api/src/models/post.ts b/apps/miiverse-api/src/models/post.ts index a8cda186..0a190a27 100644 --- a/apps/miiverse-api/src/models/post.ts +++ b/apps/miiverse-api/src/models/post.ts @@ -124,6 +124,19 @@ PostSchema.index({ removed: 1 }); +// Index for post count on community page +PostSchema.index({ + community_id: 1, + removed: 1, + parent: 1 +}); + +// Index for post metrics +PostSchema.index({ + message_to_pid: 1, + created_at: 1 +}); + PostSchema.method('del', async function del(reason: string, pid: number) { this.removed = true; this.removed_by = pid; From 936a93d68829be377315a8404f7cd809cea9ac39 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 12:56:58 +0200 Subject: [PATCH 004/103] feat: add more context to post and report dtos --- .../internal/contract/admin/report.ts | 15 +++++++-- .../services/internal/contract/community.ts | 33 ++++++++++++++++--- .../src/services/internal/contract/post.ts | 6 +++- .../services/internal/routes/activityFeeds.ts | 16 +++++++-- .../internal/routes/admin/adminReports.ts | 15 +++++++-- .../internal/routes/communityPosts.ts | 11 +++++-- .../src/services/internal/routes/posts.ts | 9 +++-- .../src/services/internal/routes/userPosts.ts | 6 +++- 8 files changed, 93 insertions(+), 18 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/admin/report.ts b/apps/miiverse-api/src/services/internal/contract/admin/report.ts index 6ad26dec..aa419851 100644 --- a/apps/miiverse-api/src/services/internal/contract/admin/report.ts +++ b/apps/miiverse-api/src/services/internal/contract/admin/report.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { HydratedReportDocument } from '@/types/mongoose/report'; import type { HydratedPostDocument } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const reportSchema = z.object({ id: z.string(), @@ -9,6 +12,7 @@ export const reportSchema = z.object({ post: postSchema.nullable(), reporter: z.object({ pid: z.number(), + user: shallowUserSchema.nullable(), reasonId: z.number(), message: z.string() }), @@ -16,6 +20,7 @@ export const reportSchema = z.object({ isResolved: z.boolean(), resolvedAt: z.date().nullable(), pid: z.number().nullable(), + user: shallowUserSchema.nullable(), note: z.string().nullable(), reason: z.enum(['reportResolved', 'similarReportResolved']).nullable() }) @@ -23,24 +28,30 @@ export const reportSchema = z.object({ export type ReportDto = z.infer; -export function mapReport(report: HydratedReportDocument, post: HydratedPostDocument | null): ReportDto { +export function mapReport(report: HydratedReportDocument, users: HydratedSettingsDocument[], post: HydratedPostDocument | null, community: HydratedCommunityDocument | null): ReportDto { const hasPost = post && !post.removed ? post : null; const isResolved = report.resolved || !hasPost; + + const reporter = users.find(v => v.pid === report.reported_by); + const resolver = report.resolved_by ? users.find(v => v.pid === report.resolved_by) : null; + return { id: report.id, createdAt: report.created_at, reporter: { pid: report.reported_by, + user: reporter ? mapShallowUser(reporter) : null, reasonId: report.reason, message: report.message }, resolved: { resolvedAt: report.resolved_at ?? null, pid: report.resolved_by ?? null, + user: resolver ? mapShallowUser(resolver) : null, isResolved: isResolved, note: report.note ?? null, reason: report.resolved ? 'reportResolved' : 'similarReportResolved' }, - post: post ? mapPost(post) : null + post: post ? mapPost(post, community) : null }; } diff --git a/apps/miiverse-api/src/services/internal/contract/community.ts b/apps/miiverse-api/src/services/internal/contract/community.ts index 0797f941..f017dee6 100644 --- a/apps/miiverse-api/src/services/internal/contract/community.ts +++ b/apps/miiverse-api/src/services/internal/contract/community.ts @@ -50,6 +50,16 @@ export const communitySchema = asOpenapi('Community', z.object({ export type CommunityDto = z.infer; +export const shallowCommunitySchema = asOpenapi('ShallowCommunity', z.object({ + id: z.string(), + olive_community_id: z.string(), + name: z.string(), + type: z.number(), + iconImagePaths: communityIconsSchema +})); + +export type ShallowCommunityDto = z.infer; + export const communityStatsSchema = z.object({ id: z.string(), olive_community_id: z.string(), @@ -80,23 +90,36 @@ export function mapCommunityType(type: number): CommunityCategoryEnum { export function mapCommunity(comm: HydratedCommunityDocument): CommunityDto { const imageId = comm.parent ? comm.parent : comm.olive_community_id; + const shallowCommunity = mapShallowCommunity(comm); return { - id: comm.community_id, - olive_community_id: comm.olive_community_id, + id: shallowCommunity.id, + olive_community_id: shallowCommunity.olive_community_id, + type: shallowCommunity.type, + name: shallowCommunity.name, + iconImagePaths: shallowCommunity.iconImagePaths, + parentId: comm.parent ?? null, titleIds: comm.title_id ?? [], - type: comm.type, permissions: comm.permissions, adminPids: comm.admins ?? [], category: mapCommunityType(comm.type), - name: comm.name, description: comm.description, followerCount: comm.followers, ctrHeaderImagePath: comm.ctr_header ?? `/headers/${imageId}/3DS.png`, hasLegacyCtrHeader: !comm.ctr_header, wupHeaderImagePath: comm.wup_header ?? `/headers/${imageId}/WiiU.png`, shotMode: comm.shot_mode ?? null, - extraShotTitleIds: comm.shot_extra_title_id ?? [], + extraShotTitleIds: comm.shot_extra_title_id ?? [] + }; +} + +export function mapShallowCommunity(comm: HydratedCommunityDocument): ShallowCommunityDto { + const imageId = comm.parent ? comm.parent : comm.olive_community_id; + return { + id: comm.community_id, + olive_community_id: comm.olive_community_id, + type: comm.type, + name: comm.name, iconImagePaths: { 32: comm.icon_paths?.[32] ?? `/icons/${imageId}/32.png`, 48: comm.icon_paths?.[48] ?? `/icons/${imageId}/48.png`, diff --git a/apps/miiverse-api/src/services/internal/contract/post.ts b/apps/miiverse-api/src/services/internal/contract/post.ts index 1b4c207f..3cde8a43 100644 --- a/apps/miiverse-api/src/services/internal/contract/post.ts +++ b/apps/miiverse-api/src/services/internal/contract/post.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowCommunity, shallowCommunitySchema } from '@/services/internal/contract/community'; import type { IPost } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; export const postSchema = asOpenapi('Post', z.object({ id: z.string(), @@ -22,6 +24,7 @@ export const postSchema = asOpenapi('Post', z.object({ topic_tag: z.string().optional(), community_id: z.string(), // number + community: shallowCommunitySchema.nullable(), created_at: z.string(), // ISO Z feeling_id: z.number().optional(), @@ -63,7 +66,7 @@ export type PostDto = z.infer; * @param post Database post type * @returns API/frontend post type */ -export function mapPost(post: IPost): PostDto { +export function mapPost(post: IPost, comm: HydratedCommunityDocument | null): PostDto { return { id: post.id, title_id: post.title_id, @@ -84,6 +87,7 @@ export function mapPost(post: IPost): PostDto { topic_tag: post.topic_tag, community_id: post.community_id, + community: comm ? mapShallowCommunity(comm) : null, created_at: post.created_at.toISOString(), feeling_id: post.feeling_id, diff --git a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts index 5666cba7..5b47bcb1 100644 --- a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts +++ b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts @@ -5,6 +5,7 @@ import { guards } from '@/services/internal/middleware/guards'; import { mapPost, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Community } from '@/models/community'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -30,7 +31,10 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); @@ -64,7 +68,10 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); @@ -99,6 +106,9 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts index eb825c1a..5af797d8 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts @@ -11,6 +11,8 @@ import { createNewPostDeletionNotification } from '@/services/internal/utils/not import { deleteOptional } from '@/services/internal/utils'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; +import { Community } from '@/models/community'; +import { Settings } from '@/models/settings'; export const adminReportsRouter = createInternalApiRouter(); @@ -45,9 +47,18 @@ adminReportsRouter.get({ const posts = await Post.find( { id: { $in: postIds } } ); - const postMap = new Map(posts.map(p => [p.id, p])); - let reports = rawReports.map(v => mapReport(v, postMap.get(v.post_id) ?? null)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const relatedUserIds = rawReports.flatMap(v => [v.reported_by, v.resolved_by]).filter((v): v is number => !!v); + const users = await Settings.find({ pid: { $in: relatedUserIds } }); + + let reports = rawReports.map((report) => { + const post = posts.find(v => v.id === report.post_id) ?? null; + const community = post ? communities.find(v => v.olive_community_id === post.community_id) ?? null : null; + return mapReport(report, users, post, community); + }); // Reports can be resolved by the original post being removed, this is not set in the DB // This is why pagination is not possible when filtering for resolved states diff --git a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts index f83ce32b..c000b03a 100644 --- a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts @@ -5,6 +5,7 @@ import { guards } from '@/services/internal/middleware/guards'; import { mapPost, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Community } from '@/models/community'; export const communityPostsRouter = createInternalApiRouter(); @@ -30,7 +31,10 @@ communityPostsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); @@ -56,6 +60,9 @@ communityPostsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index c6f859d9..37091288 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -11,6 +11,7 @@ import { postIdObjSchema, postIdSchema } from '@/services/internal/schemas'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { createLogEntry } from '@/services/internal/utils/auditLogs'; +import { Community } from '@/models/community'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -60,7 +61,10 @@ postsRouter.get({ .limit(query.limit); const total = await Post.countDocuments(dbQuery); - return mapPage(total, posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapPage(total, posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); @@ -82,8 +86,9 @@ postsRouter.get({ if (!post) { throw new errors.notFound('Post not found'); } + const community = await Community.findOne({ olive_community_id: post.community_id }); - return mapPost(post); + return mapPost(post, community); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/userPosts.ts b/apps/miiverse-api/src/services/internal/routes/userPosts.ts index 0e394a0f..cdab82e3 100644 --- a/apps/miiverse-api/src/services/internal/routes/userPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/userPosts.ts @@ -7,6 +7,7 @@ import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/c import { createInternalApiRouter } from '@/services/internal/builder/router'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { Settings } from '@/models/settings'; +import { Community } from '@/models/community'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -48,6 +49,9 @@ userPostsRouter.get({ .limit(query.limit); const total = await Post.countDocuments(dbQuery); - return mapPage(total, posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + return mapPage(total, posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); From edcf95c9995b7e4b32c670e4f2a7f67b6d24bec4 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:13:32 +0200 Subject: [PATCH 005/103] feat: add extra context to noticiations --- .../internal/contract/notification.ts | 22 ++++++++++++++----- .../services/internal/routes/notification.ts | 10 ++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 1a1d23b7..521b633a 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -1,12 +1,15 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { INotification } from '@/types/mongoose/notification'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['follow', 'notice'])); export type NotificationType = z.infer; export const notificationSchema = z.object({ toPid: z.number(), + toUser: shallowUserSchema.nullable(), resourceId: z.string(), link: z.string().nullable(), imageUrl: z.string(), @@ -16,24 +19,31 @@ export const notificationSchema = z.object({ type: notificationTypeSchema, users: z.array(z.object({ pid: z.number(), - timestamp: z.date() + timestamp: z.date(), + user: shallowUserSchema.nullable() })) }).openapi('Notification'); export type NotificationDto = z.infer; -export function mapNotification(notif: INotification): NotificationDto { +export function mapNotification(notif: INotification, users: HydratedSettingsDocument[]): NotificationDto { + const toUser = users.find(u => u.pid === Number(notif.pid)); return { toPid: Number(notif.pid), + toUser: toUser ? mapShallowUser(toUser) : null, resourceId: notif.objectID, read: notif.read, updatedAt: notif.lastUpdated, link: notif.link, type: notif.type as any, - users: notif.users.map(v => ({ - pid: Number(v.user), - timestamp: v.timestamp - })), + users: notif.users.map((v) => { + const user = users.find(u => u.pid === Number(v.user)); + return { + pid: Number(v.user), + timestamp: v.timestamp, + user: user ? mapShallowUser(user) : null + }; + }), content: notif.text, imageUrl: notif.image }; diff --git a/apps/miiverse-api/src/services/internal/routes/notification.ts b/apps/miiverse-api/src/services/internal/routes/notification.ts index dfb83aa8..fc5c22f7 100644 --- a/apps/miiverse-api/src/services/internal/routes/notification.ts +++ b/apps/miiverse-api/src/services/internal/routes/notification.ts @@ -5,6 +5,7 @@ import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/c import { deleteOptional } from '@/services/internal/utils'; import { Notification } from '@/models/notification'; import { mapNotification, notificationSchema } from '@/services/internal/contract/notification'; +import { Settings } from '@/models/settings'; import type { RootFilterQuery } from 'mongoose'; import type { INotification } from '@/types/mongoose/notification'; @@ -34,6 +35,13 @@ notificationsRouter.get({ .skip(query.offset); const total = await Notification.countDocuments(dbQuery); + const relatedUserIds = notifications.reduce((acc, v) => { + acc.push(Number(v.pid)); + acc.push(...v.users.map(u => Number(u.user))); + return acc; + }, []); + const users = await Settings.find({ pid: { $in: relatedUserIds } }); + if (query.markAsRead) { await Notification.updateMany({ _id: { @@ -46,6 +54,6 @@ notificationsRouter.get({ }); } - return mapPage(total, notifications.map(v => mapNotification(v))); + return mapPage(total, notifications.map(v => mapNotification(v, users))); } }); From 353c5612eefe3a06b08b7c4ee6e0cf54581a5224 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:22:26 +0200 Subject: [PATCH 006/103] feat: add all users to notificationDto --- .../internal/contract/notification.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 521b633a..7043b798 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -28,6 +28,30 @@ export type NotificationDto = z.infer; export function mapNotification(notif: INotification, users: HydratedSettingsDocument[]): NotificationDto { const toUser = users.find(u => u.pid === Number(notif.pid)); + const type = notif.type as NotificationType; + + const followUsers: NotificationDto['users'] = []; + if (type === 'follow') { + const pid = Number(notif.objectID); + const user = users.find(u => u.pid === pid); + followUsers.push({ + pid, + timestamp: notif.lastUpdated, // Not actually correct, but whatever + user: user ? mapShallowUser(user) : null + }); + + const restUsers = notif.users.map((v) => { + const pid = Number(v.user); + const user = users.find(u => u.pid === pid); + return { + pid, + timestamp: v.timestamp, + user: user ? mapShallowUser(user) : null + }; + }); + followUsers.push(...restUsers); + } + return { toPid: Number(notif.pid), toUser: toUser ? mapShallowUser(toUser) : null, @@ -35,15 +59,8 @@ export function mapNotification(notif: INotification, users: HydratedSettingsDoc read: notif.read, updatedAt: notif.lastUpdated, link: notif.link, - type: notif.type as any, - users: notif.users.map((v) => { - const user = users.find(u => u.pid === Number(v.user)); - return { - pid: Number(v.user), - timestamp: v.timestamp, - user: user ? mapShallowUser(user) : null - }; - }), + type, + users: followUsers, content: notif.text, imageUrl: notif.image }; From 4d49283f4de8eea7cc6e3ed885febbabe707a7d4 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:32:37 +0200 Subject: [PATCH 007/103] feat: port topic pages to internal API --- .../src/services/juxt-web/routes/console/topics.tsx | 6 ++++-- .../src/services/juxt-web/views/web/topics.tsx | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx index 339711b0..e51d513d 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx @@ -22,7 +22,8 @@ topicsRouter.get('/', async function (req, res) { }); const userContent = hasAuth() ? auth().self.content : null; - const posts = await POST.find({ topic_tag: query.topic_tag }).sort({ created_at: -1 }).limit(query.limit); + const { data: postsPage } = await req.api.posts.list({ topic_tag: query.topic_tag, limit: query.limit }); + const posts = postsPage.items; const nextLink = `/topics/more?topic_tag=${query.topic_tag}&offset=${posts.length}&pjax=true`; @@ -53,7 +54,8 @@ topicsRouter.get('/more', async function (req, res) { }); const userContent = hasAuth() ? auth().self.content : null; - const posts = await POST.find({ topic_tag: query.topic_tag }).sort({ created_at: -1 }).skip(query.offset).limit(query.limit); + const { data: postsPage } = await req.api.posts.list({ topic_tag: query.topic_tag, limit: query.limit, offset: query.offset }); + const posts = postsPage.items; const nextLink = `/topics/more?topic_tag=${query.topic_tag}&offset=${query.offset + posts.length}&pjax=true`; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx index f5e8d40b..a1a55109 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx @@ -4,14 +4,12 @@ import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalVie import { WebPostListView } from '@/services/juxt-web/views/web/postList'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; -import type { InferSchemaType } from 'mongoose'; -import type { PostSchema } from '@/models/post'; -import type { SelfContent } from '@/api/generated'; +import type { Post, SelfContent } from '@/api/generated'; export type TopicTagViewProps = { title: string; userContent: SelfContent | null; - posts: InferSchemaType[]; + posts: Post[]; nextLink: string; }; From 026edaa6373e34e283c0c7377f1fa5433e42659c Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:34:14 +0200 Subject: [PATCH 008/103] feat: remove requirement for cache in newPostView --- .../src/services/juxt-web/routes/console/posts.tsx | 5 +++-- .../src/services/juxt-web/views/ctr/newPostView.tsx | 8 ++------ .../src/services/juxt-web/views/portal/newPostView.tsx | 8 ++------ .../src/services/juxt-web/views/web/newPostView.tsx | 4 +--- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 4a25502b..fa28de36 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -24,6 +24,7 @@ import type { Request, Response } from 'express'; import type { PaintingUrls } from '@/images'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; import type { EmpathyActionEnum } from '@/api/generated'; +import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; const storage = multer.memoryStorage(); const upload = multer({ storage }); export const postsRouter = express.Router(); @@ -231,9 +232,9 @@ postsRouter.get('/:post_id/create', async function (req, res) { const shotMode = getShotMode(community, auth().paramPackData); - const props = { + const props: NewPostViewProps = { id: parent.community_id, - pid: parent.pid, + name: parent.screen_name, url: `/posts/${parent.id}/new`, show: 'post', shotMode, diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx index 40095c0f..de1177f3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx @@ -5,7 +5,6 @@ import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import type { ReactNode } from 'react'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; @@ -40,9 +39,8 @@ const empathies = [ export function CtrNewPostView(props: NewPostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const { bannerUrl, legacy } = props.community ? url.ctrHeader(props.community) : {}; - const name = props.name ?? cache.getUserName(props.pid ?? 0); + const name = props.name; return (
+ diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx index 6664c1fa..38a3c8d9 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx @@ -3,7 +3,6 @@ import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/navbar'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import type { ReactNode } from 'react'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; @@ -50,9 +49,8 @@ const empathies = [ export function PortalNewPostView(props: NewPostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); - const name = props.name ?? cache.getUserName(props.pid ?? 0); + const name = props.name; return (
@@ -137,10 +135,8 @@ export function PortalNewPostView(props: NewPostViewProps): ReactNode { } export function PortalNewPostPage(props: NewPostViewProps): ReactNode { - const cache = useCache(); - const name = props.name ?? cache.getUserName(props.pid ?? 0); return ( - + diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx index a0caee60..8beb4b4a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx @@ -47,9 +47,7 @@ const empathies = [ export type NewPostViewProps = { id: string; - // must provide name OR pid - name?: string; - pid?: number; + name: string; // Username or community name url: string; show: string; // must provide messagePid OR community From dc302462c72828b45c36e1f3fa7c3e4b14e6c537 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:34:45 +0200 Subject: [PATCH 009/103] feat: remove requirement for cache in notifications --- .../juxt-web/views/ctr/notificationListView.tsx | 9 ++++----- .../juxt-web/views/portal/notificationListView.tsx | 9 ++++----- .../juxt-web/views/web/notificationListView.tsx | 10 ++++------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index 29e27a27..ffe6891c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -2,17 +2,16 @@ import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import { CtrMiiIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrMiiIcon'; import { CtrIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrIcon'; import { humanFromNow } from '@/util'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { ShallowUser } from '@/api/generated'; function CtrNotificationItem(props: NotificationItemProps): ReactNode { - const cache = useCache(); const notif = props.notification; if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; let i18nKey: TranslationKey = 'notifications.new_follower/one'; if (notif.users.length === 2) { @@ -38,8 +37,8 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { count_other: Math.max(0, notif.users.length - 2) }} components={{ - follower_one: , - follower_two: + follower_one: , + follower_two: }} /> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 794363d0..4f3b01f4 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -1,19 +1,18 @@ import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/navbar'; import { humanFromNow } from '@/util'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalMiiIcon } from '@/services/juxt-web/views/portal/components/ui/PortalMiiIcon'; import { PortalIcon } from '@/services/juxt-web/views/portal/components/ui/PortalIcon'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { ShallowUser } from '@/api/generated'; function PortalNotificationItem(props: NotificationItemProps): ReactNode { - const cache = useCache(); const notif = props.notification; if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; let i18nKey: TranslationKey = 'notifications.new_follower/one'; if (notif.users.length === 2) { @@ -39,8 +38,8 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { count_other: Math.max(0, notif.users.length - 2) }} components={{ - follower_one: , - follower_two: + follower_one: , + follower_two: }} /> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 814b7b36..c818a4ba 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -3,11 +3,10 @@ import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalView'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { Notification } from '@/api/generated'; +import type { Notification, ShallowUser } from '@/api/generated'; export type NotificationWrapperViewProps = { children?: ReactNode; @@ -23,10 +22,9 @@ export type NotificationItemProps = { function WebNotificationItem(props: NotificationItemProps): ReactNode { const url = useUrl(); - const cache = useCache(); const notif = props.notification; if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; let i18nKey: TranslationKey = 'notifications.new_follower/one'; if (notif.users.length === 2) { @@ -54,8 +52,8 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { count_other: Math.max(0, notif.users.length - 2) }} components={{ - follower_one: , - follower_two: + follower_one: , + follower_two: }} /> From 7012b6e801183ff5cbd511407b783a5617718802 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:35:10 +0200 Subject: [PATCH 010/103] feat: remove requirement for cache in post view --- .../src/services/juxt-web/views/ctr/post.tsx | 4 +--- .../src/services/juxt-web/views/portal/post.tsx | 4 +--- .../src/services/juxt-web/views/web/post.tsx | 10 +++------- .../src/services/juxt-web/views/web/postList.tsx | 4 +--- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx index f9c47ccd..502931e1 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx @@ -1,7 +1,6 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { CtrMiiIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrMiiIcon'; import { CtrButton } from '@/services/juxt-web/views/ctr/components/ui/CtrButton'; @@ -41,7 +40,6 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { export function CtrPostView(props: PostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const post = props.post; const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; @@ -88,7 +86,7 @@ export function CtrPostView(props: PostViewProps): ReactNode { - {cache.getCommunityName(post.community_id ?? '')} + {post.community.name} ) : null} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx index 58ed3e7b..c731c600 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx @@ -1,7 +1,6 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { PortalUIIcon } from '@/services/juxt-web/views/portal/components/ui/PortalUIIcon'; import { T } from '@/services/juxt-web/views/common/components/T'; @@ -40,7 +39,6 @@ function PortalPostScreenshot(props: PostScreenshotProps): ReactNode { export function PortalPostView(props: PostViewProps): ReactNode { const url = useUrl(); - const cache = useCache(); const user = useUser(); const post = props.post; const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; @@ -89,7 +87,7 @@ export function PortalPostView(props: PostViewProps): ReactNode { - {cache.getCommunityName(post.community_id ?? '')} + {post.community.name} ) : null} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index 87dea09b..b4358b94 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -1,17 +1,14 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { WebUIIcon } from '@/services/juxt-web/views/web/components/ui/WebUIIcon'; import { T } from '@/services/juxt-web/views/common/components/T'; -import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { PostSchema } from '@/models/post'; import type { Post, SelfContent } from '@/api/generated'; export type PostScreenshotProps = { - post: InferSchemaType | Post; + post: Post; }; export function WebPostScreenshot(props: PostScreenshotProps): ReactNode { @@ -26,7 +23,7 @@ export function WebPostScreenshot(props: PostScreenshotProps): ReactNode { export type PostViewProps = { userContent?: SelfContent | null; - post: InferSchemaType | Post; + post: Post; isReply?: boolean; isMainPost?: boolean; }; @@ -34,7 +31,6 @@ export type PostViewProps = { export function WebPostView(props: PostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const post = props.post; const isModerator = user.perms.moderator; const canAccessContent = !post.removed || isModerator; @@ -76,7 +72,7 @@ export function WebPostView(props: PostViewProps): ReactNode {

{moment(post.created_at).fromNow()} {' - '} - {cache.getCommunityName(post.community_id ?? '')} + {post.community.name}

diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx index 41413fbd..57eb8349 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx @@ -1,13 +1,11 @@ import { WebPostView } from '@/services/juxt-web/views/web/post'; import { T } from '@/services/juxt-web/views/common/components/T'; -import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { PostSchema } from '@/models/post'; import type { Post, SelfContent } from '@/api/generated'; export type PostListViewProps = { userContent: SelfContent | null; - posts: InferSchemaType[] | Post[]; + posts: Post[]; nextLink: string; }; From 18a10c0e99672b6c743b084d8b98fe3a05f866b5 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:35:34 +0200 Subject: [PATCH 011/103] feat: remove requirement for cache in moderation pages --- .../juxt-web/views/web/admin/moderateUserView.tsx | 9 +++------ .../services/juxt-web/views/web/admin/reportListView.tsx | 4 +--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderateUserView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderateUserView.tsx index b66144ff..77ebce10 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderateUserView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderateUserView.tsx @@ -5,7 +5,6 @@ import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebPostView } from '@/services/juxt-web/views/web/post'; import { WebUserPageMeta, WebUserTier } from '@/services/juxt-web/views/web/userPageView'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; import type { AdminUserProfile, AuditLog, ModerationProfile, Post, Report } from '@/api/generated'; @@ -28,7 +27,6 @@ type ModerateUserReportProps = { function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { const { reporter, resolved } = props.report; const createdAt = new Date(props.report.createdAt); - const cache = useCache(); const url = useUrl(); return ( @@ -44,7 +42,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Reported By: {' '} - {cache.getUserName(reporter.pid)} + {reporter.user.miiName} {' '} {moment(createdAt).fromNow()} @@ -72,7 +70,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Resolved By: {' '} - {resolved.pid ? cache.getUserName(resolved.pid) : 'Nobody'} + {resolved.user?.miiName ?? 'Nobody'} {' '} {moment(resolved.resolvedAt).fromNow()} @@ -92,7 +90,6 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { export function WebModerateUserView(props: ModerateUserViewProps): ReactNode { const url = useUrl(); - const cache = useCache(); const profile = props.profile; const pnidName = profile.miiName; const head = ( @@ -302,7 +299,7 @@ export function WebModerateUserView(props: ModerateUserViewProps): ReactNode { Removed By: - {post.removed_by ? cache.getUserName(post.removed_by) : 'Nobody'} + {post.removed_by ?? 'Nobody'} {moment(post.removed_at).fromNow()} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx index 91c944c2..310adca3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx @@ -2,7 +2,6 @@ import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebModerationTabs } from '@/services/juxt-web/views/web/admin/admin'; import { WebPostView } from '@/services/juxt-web/views/web/post'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { humanDate, humanFromNow } from '@/util'; import { WebMiiIcon } from '@/services/juxt-web/views/web/components/ui/WebMiiIcon'; import type { ReactNode } from 'react'; @@ -21,7 +20,6 @@ export type ReportProps = { }; function Report(props: ReportProps): ReactNode { - const cache = useCache(); const createdAt = new Date(props.report.createdAt); const reporter = props.report.reporter; @@ -34,7 +32,7 @@ function Report(props: ReportProps): ReactNode { - {`Reported by ${cache.getUserName(reporter.pid)}`} + {`Reported by ${reporter.user.miiName}`} {' - '} {reporter.pid} From 36d6862d1071770b1f5a0ef9eda82096eb43da36 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:45:08 +0200 Subject: [PATCH 012/103] feat: add friend request support to internal API --- .../src/services/internal/contract/self.ts | 19 +++++++++++ .../src/services/internal/routes/self.ts | 34 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/miiverse-api/src/services/internal/contract/self.ts b/apps/miiverse-api/src/services/internal/contract/self.ts index 8bd9385a..62dfd907 100644 --- a/apps/miiverse-api/src/services/internal/contract/self.ts +++ b/apps/miiverse-api/src/services/internal/contract/self.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; +import type { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; import type { AccountData } from '@/types/common/account-data'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const banCodes = z.enum(['network_ban', 'temp_ban', 'perma_ban']).openapi('BanCodeEnum'); @@ -44,6 +47,14 @@ export const selfNotificationCountSchema = z.object({ export type SelfNotificationCountDto = z.infer; +export const selfFriendRequestSchema = z.object({ + sentAt: z.date(), + sender: shallowUserSchema, + message: z.string() +}).openapi('SelfFriendRequest'); + +export type SelfFriendRequestDto = z.infer; + const baseSelf: SelfDto = { pid: 0, username: '', @@ -113,3 +124,11 @@ export function mapSelfNotificationCount(unreadNotifications: number): SelfNotif unreadNotifications }; } + +export function mapSelfFriendRequest(req: FriendRequest, user: HydratedSettingsDocument): SelfFriendRequestDto { + return { + sentAt: new Date(Number(req.sent) * 1000), + sender: mapShallowUser(user), + message: req.message + }; +} diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts index 1f3239d4..105199f9 100644 --- a/apps/miiverse-api/src/services/internal/routes/self.ts +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -1,9 +1,12 @@ +import { z } from 'zod'; import { guards } from '@/services/internal/middleware/guards'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { errors } from '@/services/internal/errors'; -import { mapBannedSelf, mapSelf, mapSelfNotificationCount, selfNotificationCountSchema, selfSchema } from '@/services/internal/contract/self'; +import { mapBannedSelf, mapSelf, mapSelfFriendRequest, mapSelfNotificationCount, selfFriendRequestSchema, selfNotificationCountSchema, selfSchema } from '@/services/internal/contract/self'; import { Settings } from '@/models/settings'; import { Notification } from '@/models/notification'; +import { getUserFriendRequestsIncoming } from '@/util'; +import type { SelfFriendRequestDto } from '@/services/internal/contract/self'; export const selfRouter = createInternalApiRouter(); @@ -84,3 +87,32 @@ selfRouter.get({ return mapSelfNotificationCount(notificationCount); } }); + +selfRouter.get({ + path: '/self/friend-requests', + name: 'self.getFrienqRequests', + description: 'Get friend requests for current user', + guard: guards.user, + schema: { + response: z.array(selfFriendRequestSchema) + }, + async handler({ auth }) { + const account = auth!; + + const now = new Date(); + const allRequests = (await getUserFriendRequestsIncoming(account.pnid.pid)).reverse(); + const validRequests = allRequests.filter(request => new Date(Number(request.expires) * 1000) > new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000)); + + const senders = validRequests.map(v => v.sender); + const relatedUsers = await Settings.find({ pid: { $in: senders } }); + + return validRequests.reduce((acc, req) => { + const user = relatedUsers.find(v => v.pid === req.sender); + if (user) { + acc.push(mapSelfFriendRequest(req, user)); + } + + return acc; + }, []); + } +}); From 39ba6a96112cf31901480449325bbd52909dd475 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:50:02 +0200 Subject: [PATCH 013/103] feat: use miiverse-api for friend request views --- .../juxt-web/routes/console/notifications.tsx | 9 +++------ .../views/ctr/friendRequestListView.tsx | 18 ++++++------------ .../views/portal/friendRequestListView.tsx | 18 ++++++------------ .../views/web/friendRequestListView.tsx | 6 +++--- .../src/services/internal/routes/self.ts | 2 +- 5 files changed, 19 insertions(+), 34 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx index c544b2bc..665706a7 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx @@ -1,6 +1,5 @@ import express from 'express'; import { z } from 'zod'; -import { getUserFriendRequestsIncoming } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebNotificationListView, WebNotificationWrapperView } from '@/services/juxt-web/views/web/notificationListView'; import { PortalNotificationListView, PortalNotificationWrapperView } from '@/services/juxt-web/views/portal/notificationListView'; @@ -54,18 +53,16 @@ notificationRouter.get('/my_news', async function (req, res) { }); notificationRouter.get('/friend_requests', async function (req, res) { - const { query, auth } = parseReq(req, { + const { query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional() }) }); - const now = new Date(); - const allRequests = (await getUserFriendRequestsIncoming(auth().pid)).reverse(); - const validRequests = allRequests.filter(request => new Date(Number(request.expires) * 1000) > new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000)); + const { data: requests } = await req.api.self.getFriendRequests(); const props: FriendRequestListViewProps = { - requests: validRequests + requests }; if (query.pjax) { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx index 200b0e83..3ec393c1 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import type { ReactNode } from 'react'; @@ -9,21 +8,20 @@ import type { NotificationWrapperViewProps } from '@/services/juxt-web/views/web function CtrFriendRequestItem(props: FriendRequestItemProps): ReactNode { const url = useUrl(); - const cache = useCache(); - const senderId = props.request.sender; + const req = props.request; return (
  • - - + +

    - {cache.getUserName(senderId)} - {props.request.message} + {req.sender.miiName} + {req.message} {' '} - {moment(Number(props.request.sent) * 1000).fromNow()} + {moment(req.sentAt).fromNow()}

    @@ -32,14 +30,10 @@ function CtrFriendRequestItem(props: FriendRequestItemProps): ReactNode { } export function CtrFriendRequestListView(props: FriendRequestListViewProps): ReactNode { - const cache = useCache(); return (
      {props.requests.length === 0 ?
    • : null} {props.requests.map((req, i) => { - if (!cache.getUserName(req.sender)) { - return null; - } return ; })}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx index 7ad4a15d..eabfd29f 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/navbar'; @@ -10,21 +9,20 @@ import type { NotificationWrapperViewProps } from '@/services/juxt-web/views/web function PortalFriendRequestItem(props: FriendRequestItemProps): ReactNode { const url = useUrl(); - const cache = useCache(); - const senderId = props.request.sender; + const req = props.request; return (
  • - - + +

    - {cache.getUserName(senderId)} - {props.request.message} + {req.sender.miiName} + {req.message} {' '} - {moment(Number(props.request.sent) * 1000).fromNow()} + {moment(req.sentAt).fromNow()}

    @@ -33,14 +31,10 @@ function PortalFriendRequestItem(props: FriendRequestItemProps): ReactNode { } export function PortalFriendRequestListView(props: FriendRequestListViewProps): ReactNode { - const cache = useCache(); return (
      {props.requests.length === 0 ?
    • : null} {props.requests.map((req, i) => { - if (!cache.getUserName(req.sender)) { - return null; - } return ; })}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/friendRequestListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/friendRequestListView.tsx index 930d701f..ee2a8a96 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/friendRequestListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/friendRequestListView.tsx @@ -1,11 +1,11 @@ -import type { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; +import type { SelfFriendRequest } from '@/api/generated'; // Web doesn't render friend requests, so this file only holds the types. export type FriendRequestListViewProps = { - requests: FriendRequest[]; + requests: SelfFriendRequest[]; }; export type FriendRequestItemProps = { - request: FriendRequest; + request: SelfFriendRequest; }; diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts index 105199f9..4ba2d01e 100644 --- a/apps/miiverse-api/src/services/internal/routes/self.ts +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -90,7 +90,7 @@ selfRouter.get({ selfRouter.get({ path: '/self/friend-requests', - name: 'self.getFrienqRequests', + name: 'self.getFriendRequests', description: 'Get friend requests for current user', guard: guards.user, schema: { From 03771d1a1f21e75efd637186458af69363d16b6f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 13:51:13 +0200 Subject: [PATCH 014/103] chore: remove caching system from juxtaposition-ui --- .../services/juxt-web/routes/admin/admin.tsx | 4 +- .../services/juxt-web/routes/console/show.tsx | 6 +- .../views/common/components/RenderContext.tsx | 10 --- .../juxt-web/views/common/hooks/useCache.ts | 17 ---- apps/juxtaposition-ui/src/util.ts | 81 ------------------- 5 files changed, 2 insertions(+), 116 deletions(-) delete mode 100644 apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index f843b3fd..b106d102 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -1,7 +1,7 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; -import { getReasonMap, updateCommunityHashForAdminCommunity } from '@/util'; +import { getReasonMap } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserListView } from '@/services/juxt-web/views/web/admin/userListView'; import { WebReportListView } from '@/services/juxt-web/views/web/admin/reportListView'; @@ -230,7 +230,6 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo shotMode: body.shot_mode as any, shotModeExtraTitleIds: body.shot_extra_title_id }); - updateCommunityHashForAdminCommunity(outputCommunity); res.redirect(`/admin/communities/${outputCommunity.olive_community_id}`); }); @@ -294,7 +293,6 @@ adminRouter.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCo shotModeExtraTitleIds: body.shot_extra_title_id }); - updateCommunityHashForAdminCommunity(outputCommunity); res.redirect(`/admin/communities/${outputCommunity.olive_community_id}`); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx index cff6b838..202f7973 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx @@ -1,7 +1,7 @@ import express from 'express'; import { z } from 'zod'; import { database } from '@/database'; -import { createUser, setName } from '@/util'; +import { createUser } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebFirstRunView } from '@/services/juxt-web/views/web/firstRunView'; import { PortalFirstRunView } from '@/services/juxt-web/views/portal/firstRunView'; @@ -33,10 +33,6 @@ showRouter.get('/', async function (req, res) { } else { res.redirect('/titles'); } - - if (self) { - setName(self.pid, self.miiName); - } }); showRouter.get('/first', async function (req, res) { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx index dd866577..9895e45f 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx @@ -1,5 +1,4 @@ import { createContext, useContext } from 'react'; -import { getCommunityHash, getUserHash } from '@/util'; import type { ReactNode } from 'react'; import type { Response } from 'express'; import type { i18n } from 'i18next'; @@ -11,13 +10,6 @@ export type RenderContextContent = { developer: boolean; pid: number; uaIsConsole?: boolean; // user agent looks like a Nintendo console - usersMap: HashMap; // map of PID -> screen name - - // map of the following: - // olive_community_id -> community name - // title_id -> community name - // -id -> olive_community_id - communityMap: HashMap; }; const InternalRenderContext = createContext(null); @@ -33,8 +25,6 @@ export function useRenderContext(): RenderContextContent { export function buildContext(res: Response): RenderContextContent { const locals = res.locals; return { - usersMap: getUserHash(), - communityMap: getCommunityHash(), cdnUrl: locals.cdnURL, moderator: locals.moderator, developer: locals.developer, diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts b/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts deleted file mode 100644 index d2877171..00000000 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useRenderContext } from '@/services/juxt-web/views/common/components/RenderContext'; - -export type UseCacheValue = { - /* Convert pid into a screen name */ - getUserName: (pid: number) => string | undefined; - - /* Convert title_id or olive_community_id to the community name */ - getCommunityName: (id: string) => string | undefined; -}; - -export function useCache(): UseCacheValue { - const ctx = useRenderContext(); - return { - getCommunityName: id => ctx.communityMap.get(id), - getUserName: pid => ctx.usersMap.get(pid) - }; -} diff --git a/apps/juxtaposition-ui/src/util.ts b/apps/juxtaposition-ui/src/util.ts index a9a5d19c..cb0e6d0e 100644 --- a/apps/juxtaposition-ui/src/util.ts +++ b/apps/juxtaposition-ui/src/util.ts @@ -5,13 +5,10 @@ import { createChannel, createClient, Metadata } from 'nice-grpc'; import { AccountDefinition } from '@pretendonetwork/grpc/account/account_service'; import { FriendsDefinition } from '@pretendonetwork/grpc/friends/friends_service'; import { APIDefinition } from '@pretendonetwork/grpc/api/api_service'; -import HashMap from 'hashmap'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import crc32 from 'crc/crc32'; import { DateTime } from 'luxon'; import { z } from 'zod'; -import { database } from '@/database'; -import { COMMUNITY } from '@/models/communities'; import { logger } from '@/logger'; import { CONTENT } from '@/models/content'; import { SETTINGS } from '@/models/settings'; @@ -20,7 +17,6 @@ import { SystemType } from '@/types/common/system-types'; import { TokenType } from '@/types/common/token-types'; import type { ZodType } from 'zod'; import type { ObjectCannedACL } from '@aws-sdk/client-s3'; -import type { InferSchemaType } from 'mongoose'; import type { GetUserDataResponse as AccountGetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { GetUserDataResponse as ApiGetUserDataResponse } from '@pretendonetwork/grpc/api/get_user_data_rpc'; import type { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; @@ -28,8 +24,6 @@ import type { LoginResponse } from '@pretendonetwork/grpc/api/login_rpc'; import type { GetUserFriendPIDsResponse } from '@pretendonetwork/grpc/friends/get_user_friend_pids_rpc'; import type { ServiceToken } from '@/types/common/service-token'; import type { ParamPack } from '@/types/common/param-pack'; -import type { CommunitySchema } from '@/models/communities'; -import type { AdminCommunity } from '@/api/generated'; const gRPCFriendsChannel = createChannel(`${config.grpc.friends.host}:${config.grpc.friends.port}`); const gRPCFriendsClient = createClient(FriendsDefinition, gRPCFriendsChannel); @@ -50,79 +44,6 @@ const s3 = new S3Client({ } }); -const communityMap = new HashMap(); -const userMap = new HashMap(); - -/** - * Map from {olive_community_id, title_id} to name - * and also title_id + '-id' to olive_community_id - */ -export function getCommunityHash(): HashMap { - return communityMap; -} -/** - * Map from pid to screen_name (transformed) - */ -export function getUserHash(): HashMap { - return userMap; -} - -refreshCache(); - -function refreshCache(): void { - database.connect().then(async () => { - for await (const community of COMMUNITY.find()) { - updateCommunityHash(community); - } - logger.success('Created community index'); - - const users = await SETTINGS.find({}); - - for (const user of users) { - if (user.pid === undefined || user.screen_name === undefined) { - continue; - } - - setName(user.pid, user.screen_name); - } - logger.success('Created user index of ' + users.length + ' users'); - }).catch((error) => { - logger.error(error); - }); -} - -/** - * Updates a user's name in the user map. - */ -export function setName(pid: number, name: string): void { - userMap.set(pid, name.replace(/[\u{0080}-\u{FFFF}]/gu, '').replace(/\u202e/g, '')); -} - -/** - * Updates a community's info in the map. - */ -export function updateCommunityHash(community: Pick, 'title_id' | 'olive_community_id' | 'name'>): void { - if (community.title_id === undefined || - community.olive_community_id === undefined || - community.name === undefined - ) { - return; - } - - for (const title_id of community.title_id) { - communityMap.set(title_id, community.name); - communityMap.set(title_id + '-id', community.olive_community_id); - } - communityMap.set(community.olive_community_id, community.name); -} -export function updateCommunityHashForAdminCommunity(community: AdminCommunity): void { - updateCommunityHash({ - name: community.name, - olive_community_id: community.olive_community_id, - title_id: community.titleIds - }); -} - // TODO - This doesn't belong here, just hacking it in. Gonna redo this whole server anyway so fuck it export function getInvalidPostRegex(): RegExp { return /[^\p{L}\p{P}\d\n\r$^¨←→↑↓√|¦⇒⇔¤¢€£¥™©®+×÷=±∞`΄΅˘˙¸˛~˜°¹²³♭♪¬¯¼½¾♡♥●◆■▲▼☆★♀♂<> ]/gu; @@ -150,8 +71,6 @@ export async function createUser(pid: number, experience: number, notifications: const newContentObj = new CONTENT(newContent); await newContentObj.save(); - - setName(pid, name); } export function decodeParamPack(paramPack: string): ParamPack { From 845e9dec87b05d9f1da1b96edb923e6c2bbb3037 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 14:12:36 +0200 Subject: [PATCH 015/103] feat: add server status to internal API --- .../services/internal/contract/discovery.ts | 35 +++++++++++++++++++ .../src/services/internal/index.ts | 2 ++ .../src/services/internal/routes/discovery.ts | 24 +++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 apps/miiverse-api/src/services/internal/contract/discovery.ts create mode 100644 apps/miiverse-api/src/services/internal/routes/discovery.ts diff --git a/apps/miiverse-api/src/services/internal/contract/discovery.ts b/apps/miiverse-api/src/services/internal/contract/discovery.ts new file mode 100644 index 00000000..8c1f621e --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/discovery.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import type { HydratedEndpointDocument } from '@/models/endpoint'; + +export const discoveryStatusSchema = z.enum(['open', 'maintenance', 'closed', 'unavailable']).openapi('DiscoveryStatus'); +export type DiscoveryStatus = z.infer; + +export const discoverySchema = z.object({ + status: discoveryStatusSchema, + guestAccess: z.boolean(), + newUsers: z.boolean() +}).openapi('Discovery'); +export type DiscoveryDto = z.infer; + +export function mapDiscovery(endpoint: HydratedEndpointDocument | null): DiscoveryDto { + if (!endpoint) { + return { + status: 'unavailable', + guestAccess: false, + newUsers: false + }; + } + + const statusMap: Record = { + 3: 'maintenance', + 4: 'closed', + 0: 'open' + // No idea what other status there are + }; + + return { + status: statusMap[endpoint.status] ?? 'unavailable', + guestAccess: endpoint.guest_access, + newUsers: endpoint.new_users + }; +} diff --git a/apps/miiverse-api/src/services/internal/index.ts b/apps/miiverse-api/src/services/internal/index.ts index 37eabf8a..d6e94554 100644 --- a/apps/miiverse-api/src/services/internal/index.ts +++ b/apps/miiverse-api/src/services/internal/index.ts @@ -12,8 +12,10 @@ import { adminUsersRouter } from '@/services/internal/routes/admin/adminUsers'; import { notificationsRouter } from '@/services/internal/routes/notification'; import { adminAuditLogs } from '@/services/internal/routes/admin/adminAuditLogs'; import { adminStatsRouter } from '@/services/internal/routes/admin/adminStats'; +import { discoveryRouter } from '@/services/internal/routes/discovery'; export const internalApiRouter = express.Router(); +internalApiRouter.use('/api/v1', discoveryRouter.toRouter()); internalApiRouter.use('/api/v1', postsRouter.toRouter()); internalApiRouter.use('/api/v1', communitiesRouter.toRouter()); internalApiRouter.use('/api/v1', activityFeedsRouter.toRouter()); diff --git a/apps/miiverse-api/src/services/internal/routes/discovery.ts b/apps/miiverse-api/src/services/internal/routes/discovery.ts new file mode 100644 index 00000000..64e4ec4e --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/discovery.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { guards } from '@/services/internal/middleware/guards'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Endpoint } from '@/models/endpoint'; +import { discoverySchema, mapDiscovery } from '@/services/internal/contract/discovery'; + +export const discoveryRouter = createInternalApiRouter(); + +discoveryRouter.get({ + path: '/discovery/:environment', + name: 'discovery.get', + description: 'Get information about this current server', + guard: guards.guest, + schema: { + params: z.object({ + environment: z.string() + }), + response: discoverySchema + }, + async handler({ params }) { + const discovery = await Endpoint.findOne({ server_access_level: params.environment }); + return mapDiscovery(discovery); + } +}); From 1838f270aec103e0ebb3425e5406be0cf36a9e8d Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 14:17:36 +0200 Subject: [PATCH 016/103] feat: make endpoint discovery pull from miiverse-api --- apps/juxtaposition-ui/src/database.js | 9 ----- .../src/middleware/discovery.tsx | 37 +++++++++---------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/apps/juxtaposition-ui/src/database.js b/apps/juxtaposition-ui/src/database.js index db9a835e..7b1ef67b 100644 --- a/apps/juxtaposition-ui/src/database.js +++ b/apps/juxtaposition-ui/src/database.js @@ -1,6 +1,5 @@ import mongoose from 'mongoose'; import { CONTENT } from '@/models/content'; -import { ENDPOINT } from '@/models/endpoint'; import { POST } from '@/models/post'; import { SETTINGS } from '@/models/settings'; import { REPORT } from '@/models/report'; @@ -55,13 +54,6 @@ async function getDuplicatePosts(pid, post, olderThanMs) { }); } -async function getEndPoint(accessLevel) { - verifyConnected(); - return ENDPOINT.findOne({ - server_access_level: accessLevel - }); -} - async function getUserSettings(pid) { verifyConnected(); return SETTINGS.findOne({ pid: pid }); @@ -84,7 +76,6 @@ export const database = { connect, getPostByID, getDuplicatePosts, - getEndPoint, getUserSettings, getUserContent, getDuplicateReports diff --git a/apps/juxtaposition-ui/src/middleware/discovery.tsx b/apps/juxtaposition-ui/src/middleware/discovery.tsx index 97b7354b..07f1907c 100644 --- a/apps/juxtaposition-ui/src/middleware/discovery.tsx +++ b/apps/juxtaposition-ui/src/middleware/discovery.tsx @@ -1,37 +1,34 @@ -import { database as db } from '@/database'; import { config } from '@/config'; import { WebLoginView } from '@/services/juxt-web/views/web/loginView'; import { PortalFatalErrorView } from '@/services/juxt-web/views/portal/errorView'; import { CtrFatalErrorView } from '@/services/juxt-web/views/ctr/errorView'; +import { createInternalApiClient } from '@/api/client'; import type { RequestHandler } from 'express'; +import type { DiscoveryStatus } from '@/api/generated'; export const checkDiscovery: RequestHandler = async (request, response, next) => { - const discovery = await db.getEndPoint(config.serverEnvironment); + const api = createInternalApiClient({}); + const { data: discovery } = await api.discovery.get({ environment: config.serverEnvironment }); + const status = discovery.status; - if (!discovery || discovery.status !== 0) { - let message = ''; - const status = discovery ? discovery.status : -1; - switch (status) { - case 3: - message = 'Juxtaposition is currently under maintenance. Please try again later.'; - break; - case 4: - message = 'Juxtaposition is now closed. Thank you for your support!'; - break; - default: - message = 'Juxtaposition is currently unavailable. Please try again later.'; - break; - } + if (status !== 'open') { + const messageMap: Record = { + closed: 'Juxtaposition is now closed. Thank you for your support!', + maintenance: 'Juxtaposition is currently under maintenance. Please try again later.', + open: '', // impossible + unavailable: 'Juxtaposition is currently unavailable. Please try again later.' + }; + const message = messageMap[status]; return response.jsxForDirectory({ web: , portal: , ctr: }); - } else { - request.guest_access = discovery ? discovery.guest_access : false; - request.new_users = discovery ? discovery.new_users : false; - response.locals.cdnURL = config.cdnDomain; } + request.guest_access = discovery.guestAccess; + request.new_users = discovery.newUsers; + response.locals.cdnURL = config.cdnDomain; + next(); }; From cc0af975d33f476cec1554aabb97d064fc39df96 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 14:27:19 +0200 Subject: [PATCH 017/103] feat: removed missed usage of discovery endpoints --- .../src/middleware/discovery.tsx | 18 ++++++++------ .../services/juxt-web/routes/web/login.tsx | 24 +++++-------------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/apps/juxtaposition-ui/src/middleware/discovery.tsx b/apps/juxtaposition-ui/src/middleware/discovery.tsx index 07f1907c..c5d23990 100644 --- a/apps/juxtaposition-ui/src/middleware/discovery.tsx +++ b/apps/juxtaposition-ui/src/middleware/discovery.tsx @@ -6,19 +6,23 @@ import { createInternalApiClient } from '@/api/client'; import type { RequestHandler } from 'express'; import type { DiscoveryStatus } from '@/api/generated'; +export function getDiscoveryStatusMessage(status: DiscoveryStatus): string { + const messageMap: Record = { + closed: 'Juxtaposition is now closed. Thank you for your support!', + maintenance: 'Juxtaposition is currently under maintenance. Please try again later.', + open: 'Juxtaposition is open!', // unreachable + unavailable: 'Juxtaposition is currently unavailable. Please try again later.' + }; + return messageMap[status]; +} + export const checkDiscovery: RequestHandler = async (request, response, next) => { const api = createInternalApiClient({}); const { data: discovery } = await api.discovery.get({ environment: config.serverEnvironment }); const status = discovery.status; if (status !== 'open') { - const messageMap: Record = { - closed: 'Juxtaposition is now closed. Thank you for your support!', - maintenance: 'Juxtaposition is currently under maintenance. Please try again later.', - open: '', // impossible - unavailable: 'Juxtaposition is currently unavailable. Please try again later.' - }; - const message = messageMap[status]; + const message = getDiscoveryStatusMessage(status); return response.jsxForDirectory({ web: , portal: , diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx index b37155a1..31727341 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx @@ -1,11 +1,11 @@ import express from 'express'; import { z } from 'zod'; -import { database } from '@/database'; import { passwordLogin, getUserDataFromToken } from '@/util'; import { config } from '@/config'; import { logger } from '@/logger'; import { WebLoginView } from '@/services/juxt-web/views/web/loginView'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; +import { getDiscoveryStatusMessage } from '@/middleware/discovery'; export const loginRouter = express.Router(); const cookieDomain = config.http.cookieDomain; @@ -51,27 +51,15 @@ loginRouter.post('/', async (req, res) => { return res.jsx(); } - const discovery = await database.getEndPoint(config.serverEnvironment); - const discoveryStatus = discovery?.status ?? 5; - - let message = ''; - switch (discoveryStatus) { - case 3: - message = 'Juxt is currently undergoing maintenance. Please try again later.'; - break; - case 4: - message = 'Juxt is currently closed. Thank you for your interest.'; - break; - default: - message = 'Juxt is currently unavailable. Please try again later.'; - break; - } - if (discoveryStatus !== 0) { + const { data: discovery } = await req.api.discovery.get({ environment: config.serverEnvironment }); + const discoveryStatus = discovery.status; + if (discoveryStatus !== 'open') { return res.renderError({ code: 504, - message + message: getDiscoveryStatusMessage(discoveryStatus) }); } + const expiration = login.expiresIn * 60 * 60; res.cookie('access_token', login.accessToken, { domain: cookieDomain, maxAge: expiration }); From 525c4142c78f9e861baa2ea9138b9823e34c142d Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 16:30:44 +0200 Subject: [PATCH 018/103] feat: introduce reporting and post creation to miiverse internal API --- .../src/services/api/routes/posts.ts | 16 +- .../internal/routes/communityPosts.ts | 44 +++++ .../src/services/internal/routes/posts.ts | 97 +++++++++++ .../services/internal/utils/communities.ts | 29 ++++ .../src/services/internal/utils/posts.ts | 163 ++++++++++++++++++ 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 apps/miiverse-api/src/services/internal/utils/posts.ts diff --git a/apps/miiverse-api/src/services/api/routes/posts.ts b/apps/miiverse-api/src/services/api/routes/posts.ts index 761ece22..10b4fc6e 100644 --- a/apps/miiverse-api/src/services/api/routes/posts.ts +++ b/apps/miiverse-api/src/services/api/routes/posts.ts @@ -222,18 +222,22 @@ function canPost(community: HydratedCommunityDocument, userSettings: HydratedSet return isReply ? isOpenCommunity : isPublicPostableCommunity; } -export function getShotMode(community: HydratedCommunityDocument, pack: ParamPack | null): CommunityShotMode { - if (pack === null) { +export function getShotModeForTitleId(community: HydratedCommunityDocument, titleId: string): CommunityShotMode { + // Shots only on matching communities + if (!community.title_id.includes(titleId) && + !community.shot_extra_title_id?.includes(titleId)) { return 'block'; } - // Shots only on matching communities - if (!community.title_id.includes(pack.title_id) && - !community.shot_extra_title_id?.includes(pack.title_id)) { + return community.shot_mode as CommunityShotMode; // type validated by database +} + +export function getShotMode(community: HydratedCommunityDocument, pack: ParamPack | null): CommunityShotMode { + if (pack === null) { return 'block'; } - return community.shot_mode as CommunityShotMode; // type validated by database + return getShotModeForTitleId(community, pack.title_id); } async function newPost(request: express.Request, response: express.Response): Promise { diff --git a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts index c000b03a..a8165de3 100644 --- a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts @@ -6,6 +6,10 @@ import { mapPost, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { Community } from '@/models/community'; +import { createNewPost, postCreateSchema } from '@/services/internal/utils/posts'; +import { errors } from '@/services/internal/errors'; +import { isPostingAllowed } from '@/services/internal/utils/communities'; +import { mapSelf } from '@/services/internal/contract/self'; export const communityPostsRouter = createInternalApiRouter(); @@ -66,3 +70,43 @@ communityPostsRouter.get({ return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); } }); + +communityPostsRouter.post({ + path: '/communities/:id/posts', + name: 'communities.createPost', + guard: guards.user, + schema: { + params: z.object({ + id: z.string() + }), + body: postCreateSchema, + response: postSchema + }, + async handler({ body, params, auth }) { + const account = auth!; + + const community = await Community.findOne({ olive_community_id: params.id }); + if (!community) { + throw new errors.notFound('Community could not be found'); + } + + const self = mapSelf(account); + if (!isPostingAllowed(community, self, null)) { + throw new errors.forbidden('You can not post to this community'); + } + + const newPost = await createNewPost({ + author: { + pid: account.pnid.pid, + miiData: account.pnid.mii?.data ?? '', + screenName: account.settings?.screen_name ?? '', + verified: self.permissions.moderator + }, + body, + community, + parentPost: null + }); + + return mapPost(newPost, community); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index 37091288..04225a0e 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -12,6 +12,10 @@ import { createInternalApiRouter } from '@/services/internal/builder/router'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { createLogEntry } from '@/services/internal/utils/auditLogs'; import { Community } from '@/models/community'; +import { Report } from '@/models/report'; +import { createNewPost, postCreateSchema } from '@/services/internal/utils/posts'; +import { isPostingAllowed } from '@/services/internal/utils/communities'; +import { mapSelf } from '@/services/internal/contract/self'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -200,3 +204,96 @@ postsRouter.post({ return mapEmpathy(body.action, post); } }); + +postsRouter.post({ + path: '/posts/:post_id/report', + name: 'posts.report', + guard: guards.user, + schema: { + params: postIdObjSchema, + body: z.object({ + reasonId: z.number(), + message: z.string() + }), + response: resultSchema + }, + async handler({ body, params, auth }) { + // guards.user makes this safe + const account = auth!; + const pid = account.pnid.pid; + + const post = await Post.findOne({ + id: params.post_id, + message_to_pid: null, // messages aren't really posts + removed: false + }); + if (!post) { + throw new errors.notFound('Post not found'); + } + + const duplicateReport = await Report.findOne({ + reported_by: pid, + post_id: post.id + }); + if (duplicateReport) { + return mapResult('success'); // Silently reject duplicate reports + } + + await Report.create({ + pid: post.pid, + reported_by: pid, + post_id: post.id, + reason: body.reasonId, + message: body.message + }); + + return mapResult('success'); + } +}); + +postsRouter.post({ + path: '/posts/:post_id/replies', + name: 'posts.reply', + guard: guards.user, + schema: { + params: postIdObjSchema, + body: postCreateSchema, + response: postSchema + }, + async handler({ body, params, auth }) { + const account = auth!; + + const parentPost = await Post.findOne({ + id: params.post_id, + message_to_pid: null, + removed: false + }); + if (!parentPost) { + throw new errors.notFound('Community could not be found'); + } + + const community = await Community.findOne({ olive_community_id: parentPost.community_id }); + if (!community) { + throw new errors.notFound('Community could not be found'); + } + + const self = mapSelf(account); + if (!isPostingAllowed(community, self, parentPost)) { + throw new errors.forbidden('You can not reply to this post'); + } + + const newPost = await createNewPost({ + author: { + pid: account.pnid.pid, + miiData: account.pnid.mii?.data ?? '', + screenName: account.settings?.screen_name ?? '', + verified: self.permissions.moderator + }, + body, + community, + parentPost + }); + + return mapPost(newPost, community); + } +}); diff --git a/apps/miiverse-api/src/services/internal/utils/communities.ts b/apps/miiverse-api/src/services/internal/utils/communities.ts index c88841b4..7299551f 100644 --- a/apps/miiverse-api/src/services/internal/utils/communities.ts +++ b/apps/miiverse-api/src/services/internal/utils/communities.ts @@ -1,4 +1,7 @@ import { Community } from '@/models/community'; +import type { SelfDto } from '@/services/internal/contract/self'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedPostDocument } from '@/types/mongoose/post'; export async function generateCommunityId(length?: number | undefined): Promise { let id = crypto.getRandomValues(new Uint32Array(1)).toString().substring(0, length); @@ -26,3 +29,29 @@ export const communityPlatformDisplayMap: Record = { 1: '3DS', 2: 'Both' }; + +export function isPostingAllowed(community: HydratedCommunityDocument, user: SelfDto, parentPost: HydratedPostDocument | null): boolean { + const isReply = !!parentPost; + const isPublicPostableCommunity = community.type >= 0 && community.type < 2; + const isOpenCommunity = community.permissions.open; + + const isCommunityAdmin = community.admins?.includes(user.pid) ?? false; + const isUserLimitedFromPosting = !user.permissions.posting; + const hasAccessLevelRequirement = isReply + ? user.permissions.accessLevel >= community.permissions.minimum_new_comment_access_level + : user.permissions.accessLevel >= community.permissions.minimum_new_post_access_level; + + if (isUserLimitedFromPosting) { + return false; + } + + if (isCommunityAdmin) { + return true; // admins can always post (if not limited) + } + + if (!hasAccessLevelRequirement) { + return false; + } + + return isReply ? isOpenCommunity : isPublicPostableCommunity; +} diff --git a/apps/miiverse-api/src/services/internal/utils/posts.ts b/apps/miiverse-api/src/services/internal/utils/posts.ts new file mode 100644 index 00000000..a19b9751 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/utils/posts.ts @@ -0,0 +1,163 @@ +import { z } from 'zod'; +import { uploadPainting, uploadScreenshot } from '@/images'; +import { getShotModeForTitleId } from '@/services/api/routes/posts'; +import { getInvalidPostRegex } from '@/util'; +import { config } from '@/config'; +import { getDuplicatePosts } from '@/database'; +import { Post } from '@/models/post'; +import type { PaintingUrls } from '@/images'; +import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; + +export const postCreateSchema = z.object({ + body: z.string().optional(), + feelingId: z.number(), + isSpoiler: z.boolean().default(false), + isAppJumpable: z.boolean().default(false), + painting: z.object({ + file: z.base64(), + isBmp: z.boolean() + }).optional(), + screenshot: z.object({ + file: z.base64(), + titleId: z.string() + }).optional(), + languageId: z.number().optional(), + countryId: z.number().optional(), + platformId: z.number().optional(), + regionId: z.number().optional() +}); + +export type PostCreateBody = z.infer; + +export type PostCreateOptions = { + body: PostCreateBody; + author: { + pid: number; + screenName: string; + miiData: string; + verified: boolean; + }; + parentPost: HydratedPostDocument | null; + community: HydratedCommunityDocument; +}; + +const defaultMiiFaceFilename = 'normal_face.png'; +const miiFaceFilenameMap: Record = { + 0: 'normal_face.png', + 1: 'smile_open_mouth.png', + 2: 'wink_left.png', + 3: 'surprise_open_mouth.png', + 4: 'frustrated.png', + 5: 'sorrow.png' +}; + +/** + * Create a new post from an input body + * Warning: Does not check if it should be posted, validation should be done outside of this method + */ +export async function createNewPost(ops: PostCreateOptions): Promise { + const body = ops.body; + const postId = await generatePostUID(21); + + let paintings: PaintingUrls | null = null; + if (body.painting) { + paintings = await uploadPainting({ + blob: body.painting.file, + autodetectFormat: false, + isBmp: body.painting.isBmp, + pid: ops.author.pid, + postId + }); + if (paintings === null) { + throw new Error('Failed to upload paintings'); + } + } + + let screenshots = null; + if (body.screenshot) { + const shotMode = getShotModeForTitleId(ops.community, body.screenshot.titleId); + if (shotMode !== 'block') { + screenshots = await uploadScreenshot({ + blob: body.screenshot.file, + pid: ops.author.pid, + postId + }); + if (screenshots === null) { + throw new Error('Failed to upload screenshots'); + } + } + } + + const miiFace = miiFaceFilenameMap[body.feelingId] ?? defaultMiiFaceFilename; + + const postBody = body.body?.replaceAll('\r\n', '\n'); // Clean up \r\n + if (postBody && getInvalidPostRegex().test(postBody)) { + throw new Error('Invalid characters found in post body'); + } + + if (postBody && postBody.length > 280) { + throw new Error('Post body is top long'); + } + + const document: IPostInput = { + title_id: ops.community.title_id[0], + community_id: ops.community.olive_community_id, + screen_name: ops.author.screenName, + body: postBody, + painting: paintings?.blob ?? '', + painting_img: paintings?.img ?? '', + painting_big: paintings?.big ?? '', + screenshot: screenshots?.full ?? '', + screenshot_big: screenshots?.big ?? '', + screenshot_length: screenshots?.fullLength ?? 0, + screenshot_thumb: screenshots?.thumb ?? '', + screenshot_aspect: screenshots?.aspect ?? '', + country_id: body.countryId ?? 49, + created_at: new Date(), + feeling_id: body.feelingId, + id: postId, + is_autopost: 0, + is_spoiler: body.isSpoiler ? 1 : 0, + is_app_jumpable: body.isAppJumpable ? 1 : 0, + language_id: body.languageId, + mii: ops.author.miiData, + mii_face_url: `${config.cdnUrl}/mii/${ops.author.pid}/${miiFace}`, + pid: ops.author.pid, + platform_id: body.platformId ?? 0, + region_id: body.regionId ?? 2, + verified: ops.author.verified, + parent: ops.parentPost?.id + }; + + const maxDuplicatePostAgeMs = 5 * 60 * 1000; + const duplicatePost = await getDuplicatePosts(ops.author.pid, document, maxDuplicatePostAgeMs); + if (duplicatePost) { + throw new Error('Duplicate post found'); + } + + if (document.body === '' && document.painting === '' && document.screenshot === '') { + throw new Error('No valid post content'); + } + + const newPost = await Post.create(document); + + if (ops.parentPost) { + await Post.findOneAndUpdate({ + id: ops.parentPost.id + }, { + $inc: { + reply_count: 1 + } + }); + } + + return newPost; +} + +async function generatePostUID(length: number): Promise { + let id = Buffer.from(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(length * 2))), 'binary').toString('base64').replace(/[+/]/g, '').substring(0, length); + const inuse = await Post.findOne({ id }); + id = (inuse ? await generatePostUID(length) : id); + return id; +} From bd7f7fb28fb2ac397f55b660d5d356ef29e4e475 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 16:34:12 +0200 Subject: [PATCH 019/103] feat: implement new endpoint for reporting into frontend --- .../juxt-web/routes/console/posts.tsx | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index fa28de36..a8a0646e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -8,7 +8,6 @@ import { database } from '@/database'; import { uploadPainting, uploadScreenshot } from '@/images'; import { logger } from '@/logger'; import { POST } from '@/models/post'; -import { REPORT } from '@/models/report'; import { redisRemove } from '@/redisCache'; import { getInvalidPostRegex, getUserAccountData } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; @@ -268,36 +267,24 @@ postsRouter.get('/:post_id/report', async function (req, res) { }); postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { - const { body, auth } = parseReq(req, { + const { body } = parseReq(req, { body: z.object({ - reason: z.string(), - message: z.string(), + reason: z.coerce.number(), + message: z.string().trim(), post_id: z.string() }) }); - const { reason, message, post_id } = body; - const { data: post } = await req.api.posts.get({ post_id }); - if (!reason || !post_id || !post) { + const { data: post } = await req.api.posts.get({ post_id: body.post_id }); + if (!post) { return res.redirect('/404'); } - const duplicate = await database.getDuplicateReports(auth().pid, post_id); - if (duplicate) { - return res.redirect(`/posts/${post.id}`); - } - - const reportDoc = { - pid: post.pid, - reported_by: auth().pid, - post_id, - reason, - message, - created_at: new Date() - }; - - const reportObj = new REPORT(reportDoc); - await reportObj.save(); + await req.api.posts.report({ + post_id: post.id, + message: body.message, + reasonId: body.reason + }); return res.redirect(`/posts/${post.id}`); }); From a3059636c1236139b33392b734e8fd520e5c0828 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 16:50:02 +0200 Subject: [PATCH 020/103] feat: implement posting in internal miiverse-api --- apps/juxtaposition-ui/src/database.js | 41 +--- .../juxt-web/routes/console/posts.tsx | 207 ++++-------------- .../src/services/internal/utils/posts.ts | 5 +- 3 files changed, 48 insertions(+), 205 deletions(-) diff --git a/apps/juxtaposition-ui/src/database.js b/apps/juxtaposition-ui/src/database.js index 7b1ef67b..684c8d2b 100644 --- a/apps/juxtaposition-ui/src/database.js +++ b/apps/juxtaposition-ui/src/database.js @@ -1,8 +1,6 @@ import mongoose from 'mongoose'; import { CONTENT } from '@/models/content'; -import { POST } from '@/models/post'; import { SETTINGS } from '@/models/settings'; -import { REPORT } from '@/models/report'; import { logger } from '@/logger'; import { config } from '@/config'; @@ -28,32 +26,6 @@ function verifyConnected() { } } -export function notBanned() { - return { account_status: { $in: [0, 1] } }; -} - -async function getPostByID(postID) { - verifyConnected(); - return POST.findOne({ - id: postID - }); -} - -async function getDuplicatePosts(pid, post, olderThanMs) { - verifyConnected(); - return POST.findOne({ - pid: pid, - body: post.body, - screenshot: post.screenshot, - painting: post.painting, - created_at: { - $gte: new Date(Date.now() - olderThanMs) - }, - parent: null, - removed: false - }); -} - async function getUserSettings(pid) { verifyConnected(); return SETTINGS.findOne({ pid: pid }); @@ -64,19 +36,8 @@ async function getUserContent(pid) { return CONTENT.findOne({ pid: pid }); } -async function getDuplicateReports(pid, postID) { - verifyConnected(); - return REPORT.findOne({ - reported_by: pid, - post_id: postID - }); -} - export const database = { connect, - getPostByID, - getDuplicatePosts, getUserSettings, - getUserContent, - getDuplicateReports + getUserContent }; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index a8a0646e..dd208718 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -1,15 +1,9 @@ -import crypto from 'crypto'; import express from 'express'; import { rateLimit } from 'express-rate-limit'; import multer from 'multer'; import { z } from 'zod'; -import { config } from '@/config'; -import { database } from '@/database'; -import { uploadPainting, uploadScreenshot } from '@/images'; -import { logger } from '@/logger'; -import { POST } from '@/models/post'; import { redisRemove } from '@/redisCache'; -import { getInvalidPostRegex, getUserAccountData } from '@/util'; +import { getUserAccountData } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebPostPageView } from '@/services/juxt-web/views/web/postPageView'; import { CtrPostPageView } from '@/services/juxt-web/views/ctr/postPageView'; @@ -20,9 +14,8 @@ import { PortalReportPostPage } from '@/services/juxt-web/views/portal/reportPos import { CtrReportPostPage } from '@/services/juxt-web/views/ctr/reportPostView'; import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; import type { Request, Response } from 'express'; -import type { PaintingUrls } from '@/images'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; -import type { EmpathyActionEnum } from '@/api/generated'; +import type { EmpathyActionEnum, Post, PostCreateBody } from '@/api/generated'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; const storage = multer.memoryStorage(); const upload = multer({ storage }); @@ -290,7 +283,7 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { }); async function newPost(req: Request, res: Response): Promise { - const { params, body, files, auth, hasAuth } = parseReq(req, { + const { params, body, files, auth } = parseReq(req, { params: z.object({ post_id: z.string().optional() }), @@ -308,169 +301,57 @@ async function newPost(req: Request, res: Response): Promise { }), files: ['shot'] }); - const self = hasAuth() ? auth().self : null; - - let parentPost = null; - const postId = await generatePostUID(21); - let { data: community } = await req.api.communities.get({ id: body.community_id }); - if (params.post_id) { - parentPost = await database.getPostByID(params.post_id.toString()); - if (!parentPost) { - res.sendStatus(403); - return; - } else { - const { data: parentPostCommunity } = await req.api.communities.get({ id: parentPost.community_id ?? '' }); - community = parentPostCommunity; - if (parentPost.removed) { - res.sendStatus(400); - return; - } - } - } - if (params.post_id && (body.body === '' && body.painting === '' && body.screenshot === '' && files.shot.length == 0)) { - res.status(422); - return res.redirect('/posts/' + req.params.post_id.toString()); - } - if (!community || !self) { - res.status(403); - logger.error('Incoming post is missing data - rejecting'); - return res.redirect('/titles/show'); - } - if (!isPostingAllowed(community, self, parentPost)) { - res.status(403); - return res.redirect(`/titles/${community.olive_community_id}/new`); + const postBody: PostCreateBody = { + feelingId: body.feeling_id, + body: body.body.length > 0 ? body.body : undefined, + isSpoiler: body.spoiler, + isAppJumpable: body.is_app_jumpable, + languageId: body.language_id + }; + const paramPack = auth().paramPackData; + if (paramPack) { + postBody.platformId = Number(paramPack.platform_id); + postBody.regionId = Number(paramPack.region_id); + postBody.countryId = Number(paramPack.country_id); } - - let paintings: PaintingUrls | null = null; if (body._post_type === 'painting' && body.painting) { - paintings = await uploadPainting({ - blob: body.painting, - autodetectFormat: false, - isBmp: body.bmp, - pid: auth().pid, - postId - }); - if (paintings === null) { - res.status(422); - res.renderError({ - code: 422, - message: 'Upload failed. Please try again later.' - }); - return; - } + postBody.painting = { + file: body.painting.replace(/\0/g, '').trim(), // Remove nintendo jank + isBmp: body.bmp + }; } - let screenshots = null; - if ((body.screenshot || files.shot.length === 1) && - getShotMode(community, auth().paramPackData) !== 'block') { - screenshots = await uploadScreenshot({ - buffer: files.shot[0]?.buffer, - blob: body.screenshot, - pid: auth().pid, - postId - }); - if (screenshots === null) { - res.status(422); - res.renderError({ - code: 422, - message: 'Upload failed. Please try again later.' - }); - return; + if (body.screenshot || files.shot.length === 1) { + const bufferFile = files.shot[0]?.buffer.toString('base64'); + const base64File = body.screenshot.replace(/\0/g, '').trim(); // Remove nintendo jank + if (!paramPack) { + throw new Error('Must have parampack when uploading screenshot'); } + postBody.screenshot = { + file: bufferFile ?? base64File, + titleId: paramPack.title_id + }; } - let miiFace; - switch (body.feeling_id) { - case 1: - miiFace = 'smile_open_mouth.png'; - break; - case 2: - miiFace = 'wink_left.png'; - break; - case 3: - miiFace = 'surprise_open_mouth.png'; - break; - case 4: - miiFace = 'frustrated.png'; - break; - case 5: - miiFace = 'sorrow.png'; - break; - default: - miiFace = 'normal_face.png'; - break; - } - const postBody = body.body; - if (postBody && getInvalidPostRegex().test(postBody)) { - // TODO - Log this error - res.sendStatus(422); - return; + let newPost: Post; + if (params.post_id) { + const { data: resultPost } = await req.api.posts.reply({ + ...postBody, + post_id: params.post_id + }); + newPost = resultPost; + } else { + const { data: resultPost } = await req.api.communities.createPost({ + ...postBody, + id: body.community_id + }); + newPost = resultPost; } - /* Don't count \r\n as two */ - if (postBody && postBody.replaceAll('\r\n', '\n').length > 280) { - // TODO - Log this error - res.sendStatus(422); + if (newPost.parent) { + res.redirect('/posts/' + newPost.parent.toString()); return; } - const document = { - title_id: community.titleIds[0], - community_id: community.olive_community_id, - screen_name: self.miiName, - body: postBody, - painting: paintings?.blob ?? '', - painting_img: paintings?.img ?? '', - painting_big: paintings?.big ?? '', - screenshot: screenshots?.full ?? '', - screenshot_big: screenshots?.big ?? '', - screenshot_length: screenshots?.fullLength ?? 0, - screenshot_thumb: screenshots?.thumb ?? '', - screenshot_aspect: screenshots?.aspect ?? '', - country_id: auth().paramPackData?.country_id ?? 49, - created_at: new Date(), - feeling_id: body.feeling_id, - id: postId, - is_autopost: 0, - is_spoiler: (body.spoiler) ? 1 : 0, - is_app_jumpable: body.is_app_jumpable, - language_id: body.language_id, - mii: auth().user.mii?.data, - mii_face_url: `${config.cdnDomain}/mii/${auth().pid}/${miiFace}`, - pid: auth().pid, - platform_id: auth().paramPackData?.platform_id ?? 0, - region_id: auth().paramPackData?.region_id ?? 2, - verified: res.locals.moderator, - parent: parentPost ? parentPost.id : null - }; - const maxDuplicatePostAgeMs = 5 * 60 * 1000; - const duplicatePost = await database.getDuplicatePosts(auth().pid, document, maxDuplicatePostAgeMs); - if (duplicatePost && params.post_id) { - return res.redirect('/posts/' + params.post_id.toString()); - } - if ((document.body === '' && document.painting === '' && document.screenshot === '') || duplicatePost) { - return res.redirect('/titles/' + community.olive_community_id + '/new'); - } - const newPost = new POST(document); - newPost.save(); - if (parentPost) { - parentPost.reply_count = parentPost.reply_count + 1; - parentPost.save(); - } - if (parentPost) { - if (!params.post_id) { - throw new Error('Has parent post but no postId, this is impossible'); - } - res.redirect('/posts/' + params.post_id.toString()); - await redisRemove(`${parentPost.pid}_user_page_posts`); - } else { - res.redirect('/titles/' + community.olive_community_id + '/new'); - await redisRemove(`${auth().pid}_user_page_posts`); - } -} -async function generatePostUID(length: number): Promise { - let id = Buffer.from(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(length * 2))), 'binary').toString('base64').replace(/[+/]/g, '').substring(0, length); - const inuse = await POST.findOne({ id }); - id = (inuse ? await generatePostUID(length) : id); - return id; + res.redirect('/titles/' + newPost.community_id + '/new'); } diff --git a/apps/miiverse-api/src/services/internal/utils/posts.ts b/apps/miiverse-api/src/services/internal/utils/posts.ts index a19b9751..27d38c56 100644 --- a/apps/miiverse-api/src/services/internal/utils/posts.ts +++ b/apps/miiverse-api/src/services/internal/utils/posts.ts @@ -5,11 +5,12 @@ import { getInvalidPostRegex } from '@/util'; import { config } from '@/config'; import { getDuplicatePosts } from '@/database'; import { Post } from '@/models/post'; +import { asOpenapi } from '@/services/internal/builder/openapi'; import type { PaintingUrls } from '@/images'; import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; import type { HydratedCommunityDocument } from '@/types/mongoose/community'; -export const postCreateSchema = z.object({ +export const postCreateSchema = asOpenapi('PostCreateBody', z.object({ body: z.string().optional(), feelingId: z.number(), isSpoiler: z.boolean().default(false), @@ -26,7 +27,7 @@ export const postCreateSchema = z.object({ countryId: z.number().optional(), platformId: z.number().optional(), regionId: z.number().optional() -}); +})); export type PostCreateBody = z.infer; From 6a323301d136617ffd842ee3d97df61d77e6f543 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Apr 2026 18:12:11 +0200 Subject: [PATCH 021/103] feat: add GDPR export to miiverse-api internal API --- apps/juxtaposition-ui/eslint.config.mjs | 5 +++ .../juxt-web/routes/console/topics.tsx | 1 - .../juxt-web/routes/console/userpage.tsx | 23 ++----------- .../src/services/internal/routes/self.ts | 33 +++++++++++++++++++ 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/apps/juxtaposition-ui/eslint.config.mjs b/apps/juxtaposition-ui/eslint.config.mjs index 4911def0..53b87d1e 100644 --- a/apps/juxtaposition-ui/eslint.config.mjs +++ b/apps/juxtaposition-ui/eslint.config.mjs @@ -4,6 +4,11 @@ import { defineConfig } from 'eslint/config'; export default defineConfig([ ...eslintConfig, + { + rules: { + '@stylistic/jsx-one-expression-per-line': 'off' + } + }, { // Allow browser globals in webfiles languageOptions: { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx index e51d513d..ea5929f3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx @@ -1,7 +1,6 @@ import express from 'express'; import { z } from 'zod'; import { config } from '@/config'; -import { POST } from '@/models/post'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebPostListView } from '@/services/juxt-web/views/web/postList'; import { CtrPostListView } from '@/services/juxt-web/views/ctr/postList'; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx index e74dd025..c9adbb5f 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx @@ -2,7 +2,6 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; import { database } from '@/database'; -import { POST } from '@/models/post'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserPageView } from '@/services/juxt-web/views/web/userPageView'; import { WebPostListView } from '@/services/juxt-web/views/web/postList'; @@ -51,29 +50,11 @@ userPageRouter.get('/notifications.json', async function (req, res) { userPageRouter.get('/downloadUserData.json', async function (req, res) { const { auth } = parseReq(req); - const rawPosts = await POST.find({ pid: auth().pid }); - const userSettings = await database.getUserSettings(auth().pid); - const userContent = await database.getUserContent(auth().pid); + const { data } = await req.api.self.export(); - if (!userContent || !userSettings) { - return res.redirect('/404'); - } - - // Clean non-user data - userSettings.banned_by = null; - const postsJson = rawPosts.map(post => ({ - ...post.toJSON(), - removed_by: null - })); - - const doc = { - user_content: userContent, - user_settings: userSettings, - posts: postsJson - }; res.set('Content-Type', 'text/json'); res.set('Content-Disposition', `attachment; filename="${auth().pid}_user_data.json"`); - res.send(doc); + res.send(JSON.stringify(data, null, 2)); }); userPageRouter.get('/me/settings', async function (req, res) { diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts index 4ba2d01e..a932b3e5 100644 --- a/apps/miiverse-api/src/services/internal/routes/self.ts +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -6,6 +6,8 @@ import { mapBannedSelf, mapSelf, mapSelfFriendRequest, mapSelfNotificationCount, import { Settings } from '@/models/settings'; import { Notification } from '@/models/notification'; import { getUserFriendRequestsIncoming } from '@/util'; +import { Post } from '@/models/post'; +import { Content } from '@/models/content'; import type { SelfFriendRequestDto } from '@/services/internal/contract/self'; export const selfRouter = createInternalApiRouter(); @@ -116,3 +118,34 @@ selfRouter.get({ }, []); } }); + +selfRouter.post({ + path: '/self/export', + name: 'self.export', + description: 'Do a GDPR export for current user', + guard: guards.user, + schema: { + response: z.any() + }, + async handler({ auth }) { + const account = auth!; + const rawPosts = await Post.find({ pid: account.pnid.pid }); + const userSettings = await Settings.findOne({ pid: account.pnid.pid }); + const userContent = await Content.findOne({ pid: account.pnid.pid }); + + // Clean non-user data + if (userSettings) { + userSettings.banned_by = null; + } + const postsJson = rawPosts.map(post => ({ + ...post.toJSON(), + removed_by: null + })); + + return { + user_content: userContent, + user_settings: userSettings, + posts: postsJson + }; + } +}); From d57cf8c115cb6d762f26f8997759d4272b325283 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Wed, 29 Apr 2026 18:45:04 +0200 Subject: [PATCH 022/103] fix: reimplement automod in new endpoints --- .../juxt-web/routes/console/posts.tsx | 34 +++++++++++++------ .../src/services/internal/errors.ts | 3 +- .../src/services/internal/utils/posts.ts | 12 ++++++- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 61415f9f..39c5766a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -13,6 +13,7 @@ import { PortalNewPostPage } from '@/services/juxt-web/views/portal/newPostView' import { PortalReportPostPage } from '@/services/juxt-web/views/portal/reportPostView'; import { CtrReportPostPage } from '@/services/juxt-web/views/ctr/reportPostView'; import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; +import { InternalApiError, wrapApi } from '@/api/errors'; import type { Request, Response } from 'express'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; import type { EmpathyActionEnum, Post, PostCreateBody } from '@/api/generated'; @@ -306,6 +307,7 @@ async function newPost(req: Request, res: Response): Promise { files: ['shot'] }); + const rejectReturnUrl = params.post_id ? `/posts/${params.post_id}/create` : `/titles/${body.community_id}/create`; const postBody: PostCreateBody = { feelingId: body.feeling_id, body: body.body.length > 0 ? body.body : undefined, @@ -337,25 +339,37 @@ async function newPost(req: Request, res: Response): Promise { }; } - let newPost: Post; + let newPostResult: Post | InternalApiError | null; if (params.post_id) { - const { data: resultPost } = await req.api.posts.reply({ + const out = await wrapApi(req.api.posts.reply({ ...postBody, post_id: params.post_id - }); - newPost = resultPost; + })); + newPostResult = out.error ?? out.result ?? null; } else { - const { data: resultPost } = await req.api.communities.createPost({ + const out = await wrapApi(req.api.communities.createPost({ ...postBody, id: body.community_id - }); - newPost = resultPost; + })); + newPostResult = out.error ?? out.result ?? null; } - if (newPost.parent) { - res.redirect('/posts/' + newPost.parent.toString()); + if (newPostResult instanceof InternalApiError) { + if (newPostResult.isCode('automod_prevented')) { + const params = new URLSearchParams(); + params.append('error-text', res.i18n.t('new_post.automod_error')); + return res.redirect(rejectReturnUrl + '?' + params.toString()); + } + throw newPostResult; + } + if (!newPostResult) { + throw new Error('Null newPostResult'); + } + + if (newPostResult.parent) { + res.redirect('/posts/' + newPostResult.parent.toString()); return; } - res.redirect('/titles/' + newPost.community_id + '/new'); + res.redirect('/titles/' + newPostResult.community_id + '/new'); } diff --git a/apps/miiverse-api/src/services/internal/errors.ts b/apps/miiverse-api/src/services/internal/errors.ts index 12b073a3..db6dd619 100644 --- a/apps/miiverse-api/src/services/internal/errors.ts +++ b/apps/miiverse-api/src/services/internal/errors.ts @@ -33,7 +33,8 @@ const errorCodes = { auth_account_network_banned: 403, auth_onboarding_incomplete: 403, user_deleted: 404, - user_banned: 404 + user_banned: 404, + automod_prevented: 400 } as const; const errorCodeKeys = Object.keys(errorCodes) as [keyof typeof errorCodes, ...Array]; diff --git a/apps/miiverse-api/src/services/internal/utils/posts.ts b/apps/miiverse-api/src/services/internal/utils/posts.ts index 27d38c56..52b57456 100644 --- a/apps/miiverse-api/src/services/internal/utils/posts.ts +++ b/apps/miiverse-api/src/services/internal/utils/posts.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; import { uploadPainting, uploadScreenshot } from '@/images'; import { getShotModeForTitleId } from '@/services/api/routes/posts'; -import { getInvalidPostRegex } from '@/util'; +import { evaluateAutomodRules, getInvalidPostRegex, performAutomodAction } from '@/util'; import { config } from '@/config'; import { getDuplicatePosts } from '@/database'; import { Post } from '@/models/post'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { AutomodRule } from '@/models/automodRules'; +import { errors } from '@/services/internal/errors'; import type { PaintingUrls } from '@/images'; import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; import type { HydratedCommunityDocument } from '@/types/mongoose/community'; @@ -141,6 +143,14 @@ export async function createNewPost(ops: PostCreateOptions): Promise Date: Wed, 29 Apr 2026 18:46:45 +0200 Subject: [PATCH 023/103] chore: use new error codes system --- .../src/services/internal/routes/communityPosts.ts | 4 ++-- apps/miiverse-api/src/services/internal/routes/posts.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts index a8165de3..39b09100 100644 --- a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts @@ -87,12 +87,12 @@ communityPostsRouter.post({ const community = await Community.findOne({ olive_community_id: params.id }); if (!community) { - throw new errors.notFound('Community could not be found'); + throw errors.for('not_found'); } const self = mapSelf(account); if (!isPostingAllowed(community, self, null)) { - throw new errors.forbidden('You can not post to this community'); + throw errors.for('forbidden'); } const newPost = await createNewPost({ diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index 71fc01a9..a9540f84 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -228,7 +228,7 @@ postsRouter.post({ removed: false }); if (!post) { - throw new errors.notFound('Post not found'); + throw errors.for('not_found'); } const duplicateReport = await Report.findOne({ @@ -269,17 +269,17 @@ postsRouter.post({ removed: false }); if (!parentPost) { - throw new errors.notFound('Community could not be found'); + throw errors.for('not_found'); } const community = await Community.findOne({ olive_community_id: parentPost.community_id }); if (!community) { - throw new errors.notFound('Community could not be found'); + throw errors.for('not_found'); } const self = mapSelf(account); if (!isPostingAllowed(community, self, parentPost)) { - throw new errors.forbidden('You can not reply to this post'); + throw errors.for('forbidden'); } const newPost = await createNewPost({ From a9f6e7537ab6debb48a8578d9b6d6ef600a9ef11 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Wed, 29 Apr 2026 18:56:10 +0200 Subject: [PATCH 024/103] feat: remove useCache dependency from automod changes --- .../views/web/admin/automodLogListView.tsx | 11 ++++------- .../admin/moderate-user/moderateUserPostView.tsx | 4 +--- .../moderate-user/moderateUserReportsView.tsx | 6 ++---- .../internal/contract/admin/automodLog.ts | 8 +++++--- .../src/services/internal/contract/user.ts | 4 ++-- .../internal/routes/admin/adminAutomod.ts | 16 +++++++++++++++- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx index 57217858..1f5e29ce 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx @@ -3,7 +3,6 @@ import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebModerationTabs } from '@/services/juxt-web/views/web/admin/admin'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { WebMiiIcon } from '@/services/juxt-web/views/web/components/ui/WebMiiIcon'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { humanDate, humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { AutomodLog } from '@/api/generated'; @@ -20,20 +19,18 @@ export type AutomodLogListViewProps = { }; export function AutomodLogItem({ log }: AutomodLogItemViewProps): ReactNode { - const cache = useCache(); - return (
  • - + {`Post ${log.action} by `} - - {cache.getUserName(log.postAuthor)} + + {log.postAuthor?.miiName} {' - '} - {log.postAuthor} + {log.postAuthor?.pid} {' - '} {humanFromNow(log.createdAt)} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx index 9210434c..4e24ac20 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { WebPostView } from '@/services/juxt-web/views/web/post'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import type { ReactNode } from 'react'; import type { Post } from '@/api/generated'; @@ -10,7 +9,6 @@ export type ModerateUserRemovedPostsViewProps = { }; export function ModerateUserRemovedPostView(props: ModerateUserRemovedPostsViewProps): ReactNode { - const cache = useCache(); const url = useUrl(); return ( @@ -34,7 +32,7 @@ export function ModerateUserRemovedPostView(props: ModerateUserRemovedPostsViewP Removed By: - {post.removed_by ? cache.getUserName(post.removed_by) : 'Nobody'} + {post.removed_by} {moment(post.removed_at).fromNow()} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx index 217749c3..8dc034d2 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx @@ -1,5 +1,4 @@ import moment from 'moment'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { WebPostView } from '@/services/juxt-web/views/web/post'; import { AutomodLogItem } from '@/services/juxt-web/views/web/admin/automodLogListView'; @@ -21,7 +20,6 @@ type ModerateUserReportProps = { function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { const { reporter, resolved } = props.report; const createdAt = new Date(props.report.createdAt); - const cache = useCache(); const url = useUrl(); return ( @@ -37,7 +35,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Reported By: {' '} - {cache.getUserName(reporter.pid)} + {reporter.user?.miiName ?? 'Unknown'} {' '} {moment(createdAt).fromNow()} @@ -65,7 +63,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Resolved By: {' '} - {resolved.pid ? cache.getUserName(resolved.pid) : 'Nobody'} + {resolved.pid ? resolved.user?.miiName : 'Nobody'} {' '} {moment(resolved.resolvedAt).fromNow()} diff --git a/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts b/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts index 52b69d4e..6839883e 100644 --- a/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts +++ b/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts @@ -2,8 +2,10 @@ import { z } from 'zod'; import { mapShallowAutomodRule, shallowAutomodRuleSchema } from '@/services/internal/contract/admin/automodRule'; import { automodAction } from '@/models/automodLog'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { HydratedAutomodLogDocument } from '@/models/automodLog'; import type { HydratedAutomodRuleDocument } from '@/models/automodRules'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const automodActionEnum = asOpenapi('AutomodActionEnum', z.enum(automodAction)); @@ -12,7 +14,7 @@ export const automodLogSchema = z.object({ createdAt: z.date(), rule: shallowAutomodRuleSchema.nullable(), action: automodActionEnum, - postAuthor: z.number(), + postAuthor: shallowUserSchema.nullable(), postId: z.string().nullable(), postContent: z.object({ body: z.string().nullable() @@ -21,13 +23,13 @@ export const automodLogSchema = z.object({ export type AutomodLogDto = z.infer; -export function mapAutomodLog(log: HydratedAutomodLogDocument, rule: HydratedAutomodRuleDocument | null): AutomodLogDto { +export function mapAutomodLog(log: HydratedAutomodLogDocument, user: HydratedSettingsDocument | null, rule: HydratedAutomodRuleDocument | null): AutomodLogDto { return { id: log.id, createdAt: log.created_at, rule: rule ? mapShallowAutomodRule(rule) : null, action: log.action, - postAuthor: log.author, + postAuthor: user ? mapShallowUser(user) : null, postId: log.action === 'blocked' ? null : log.post_id, postContent: { body: log.post_content_body diff --git a/apps/miiverse-api/src/services/internal/contract/user.ts b/apps/miiverse-api/src/services/internal/contract/user.ts index 117e966a..6d3b0535 100644 --- a/apps/miiverse-api/src/services/internal/contract/user.ts +++ b/apps/miiverse-api/src/services/internal/contract/user.ts @@ -15,11 +15,11 @@ export const userBadgeSchema = z.enum([ export type UserBadgeEnum = z.infer; -export const shallowUserSchema = z.object({ +export const shallowUserSchema = asOpenapi('ShallowUser', z.object({ pid: z.number(), miiName: z.string(), accountStatus: z.number() -}).openapi('ShallowUser'); +})); export type ShallowUserDto = z.infer; diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts index 6c405f2b..434da91f 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts @@ -10,6 +10,7 @@ import { errors } from '@/services/internal/errors'; import { mapResult, resultSchema } from '@/services/internal/contract/result'; import { automodLogSchema, mapAutomodLog } from '@/services/internal/contract/admin/automodLog'; import { automodAction, AutomodLog } from '@/models/automodLog'; +import { Settings } from '@/models/settings'; import type { RootFilterQuery } from 'mongoose'; export const adminAutomodRouter = createInternalApiRouter(); @@ -164,6 +165,19 @@ adminAutomodRouter.get({ } }); - return mapPage(total, logs.map(log => mapAutomodLog(log, rules.find(v => v.id === log.rule_id) ?? null))); + const userIds = logs.map(v => v.author); + const users = await Settings.find({ + _id: { + $in: userIds + } + }); + + const mappedLogs = logs.map((log) => { + const rule = rules.find(v => v.id === log.rule_id) ?? null; + const user = users.find(v => v.pid === log.author) ?? null; + + return mapAutomodLog(log, user, rule); + }); + return mapPage(total, mappedLogs); } }); From deee88d0e9fdfe0732b9a65f304fd4cbd53f5756 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sat, 23 May 2026 22:19:22 +0200 Subject: [PATCH 025/103] fix: fix compile errors --- .../views/web/admin/moderate-user/moderateUserPostView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx index f93dbfac..cd8abbe3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx @@ -33,7 +33,7 @@ export function ModerateUserRemovedPostView(props: ModerateUserRemovedPostsViewP Removed By: {' '} - {post.removed_by ? cache.getUserName(post.removed_by) : 'Nobody'} + {post.removed_by ? post.removed_by : 'Nobody'} {' '} {moment(post.removed_at).fromNow()} From 710ba5876538bb5ac586dd630b1009b974197a56 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 24 May 2026 11:30:44 +0200 Subject: [PATCH 026/103] feat: rework post model in internal API --- .../internal/contract/admin/report.ts | 6 +- .../src/services/internal/contract/post.ts | 233 ++++++++++-------- .../services/internal/routes/activityFeeds.ts | 45 +++- .../internal/routes/admin/adminReports.ts | 5 +- .../internal/routes/communityPosts.ts | 17 +- .../src/services/internal/routes/posts.ts | 25 +- .../src/services/internal/routes/userPosts.ts | 16 +- 7 files changed, 232 insertions(+), 115 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/admin/report.ts b/apps/miiverse-api/src/services/internal/contract/admin/report.ts index aa419851..d3184a8a 100644 --- a/apps/miiverse-api/src/services/internal/contract/admin/report.ts +++ b/apps/miiverse-api/src/services/internal/contract/admin/report.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { HydratedReportDocument } from '@/types/mongoose/report'; import type { HydratedPostDocument } from '@/types/mongoose/post'; @@ -35,6 +35,8 @@ export function mapReport(report: HydratedReportDocument, users: HydratedSetting const reporter = users.find(v => v.pid === report.reported_by); const resolver = report.resolved_by ? users.find(v => v.pid === report.resolved_by) : null; + const remover = post?.removed_by ? users.find(v => v.pid === post.removed_by) ?? null : null; + return { id: report.id, createdAt: report.created_at, @@ -52,6 +54,6 @@ export function mapReport(report: HydratedReportDocument, users: HydratedSetting note: report.note ?? null, reason: report.resolved ? 'reportResolved' : 'similarReportResolved' }, - post: post ? mapPost(post, community) : null + post: post ? mapPostWithModeration(post, community, remover) : null }; } diff --git a/apps/miiverse-api/src/services/internal/contract/post.ts b/apps/miiverse-api/src/services/internal/contract/post.ts index 3cde8a43..5b0097af 100644 --- a/apps/miiverse-api/src/services/internal/contract/post.ts +++ b/apps/miiverse-api/src/services/internal/contract/post.ts @@ -1,122 +1,151 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; import { mapShallowCommunity, shallowCommunitySchema } from '@/services/internal/contract/community'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { IPost } from '@/types/mongoose/post'; import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const postSchema = asOpenapi('Post', z.object({ id: z.string(), - title_id: z.string().optional(), // number as string - screen_name: z.string(), - body: z.string(), - app_data: z.string().optional(), // nintendo base64 - - painting: z.string().optional(), // base64 or '', undef for PMs - painting_img: z.string().optional(), // URL frag (leading /) or '', undef for PMs - painting_big: z.string().optional(), // URL frag (leading /) or '', undef for PMs - screenshot: z.string().optional(), // URL frag (leading /) or '', undef for PMs - screenshot_big: z.string().optional(), // URL frag (leading /) or '', undef for PMs/old posts - screenshot_thumb: z.string().optional(), // URL frag (leading /) or '', undef for PMs/old posts - screenshot_length: z.number().optional(), - screenshot_aspect: z.string().optional(), // '4:3' '5:3' '16:9' - - search_key: z.array(z.string()).optional(), - topic_tag: z.string().optional(), - - community_id: z.string(), // number + createdAt: z.date(), + parentId: z.string().nullable(), + dmTo: z.number().nullable(), community: shallowCommunitySchema.nullable(), - created_at: z.string(), // ISO Z - feeling_id: z.number().optional(), - - is_autopost: z.boolean(), - is_community_private_autopost: z.boolean(), - is_spoiler: z.boolean(), - is_app_jumpable: z.boolean(), - - empathy_count: z.number(), - country_id: z.number(), - language_id: z.number(), - - mii: z.string(), // nintendo base64 - mii_face_url: z.string(), // full URL (cdn., r2-cdn.) - - pid: z.number(), - platform_id: z.number().optional(), - region_id: z.number().optional(), - parent: z.string().nullable(), - - reply_count: z.number(), - verified: z.boolean(), - - message_to_pid: z.string().nullable(), - - removed: z.boolean(), - removed_by: z.number().optional(), - removed_at: z.string().optional(), // ISO Z - removed_reason: z.string().optional(), - - yeahs: z.array(z.number()) + author: z.object({ + miiName: z.string(), + pid: z.number(), + verified: z.boolean() + }), + mii: z.object({ + data: z.string(), + imageUrl: z.string() + }), + + feelingId: z.number().nullable(), + body: z.string().nullable(), + painting: z.object({ + data: z.string(), + imageUrl: z.string(), + imageUrlBig: z.string() + }).nullable(), + screenshot: z.object({ + imageUrl: z.string(), + imageUrlBig: z.string(), + imageUrlThumbnail: z.string(), + length: z.number(), + aspectRatio: z.enum(['4:3', '5:3', '16:9']) + }).nullable(), + + stats: z.object({ + empathyCount: z.number(), + replyCount: z.number() + }), + yeahsBy: z.array(z.object({ + pid: z.number() + })), + + searchKey: z.array(z.string()), + topicTag: z.string().nullable(), + + isAutopost: z.boolean(), + isCommunityPrivateAutopost: z.boolean(), + isSpoiler: z.boolean(), + isAppJumpable: z.boolean(), + + countryId: z.number(), + languageId: z.number(), + platformId: z.number().nullable(), + regionId: z.number().nullable(), + titleId: z.string().nullable(), + appData: z.string().nullable(), + + moderation: z.object({ + removed: z.object({ + removedBy: shallowUserSchema.nullable(), + removedAt: z.date(), + reason: z.string() + }).nullable() + }).nullable() })); export type PostDto = z.infer; -/** - * Maps a Post from the databse to the frontend contract post type. - * Doesn't do much for now, but as the database changes, this abstraction will carry more. - * @param post Database post type - * @returns API/frontend post type - */ export function mapPost(post: IPost, comm: HydratedCommunityDocument | null): PostDto { return { id: post.id, - title_id: post.title_id, - screen_name: post.screen_name, - body: post.body, - app_data: post.app_data, - - painting: post.painting, - painting_img: post.painting_img, - painting_big: post.painting_big, - screenshot: post.screenshot, - screenshot_big: post.screenshot_big, - screenshot_thumb: post.screenshot_thumb, - screenshot_length: post.screenshot_length, - screenshot_aspect: post.screenshot_aspect, - - search_key: post.search_key, - topic_tag: post.topic_tag, - - community_id: post.community_id, + createdAt: post.created_at, + parentId: post.parent ?? null, + dmTo: post.message_to_pid ? Number(post.message_to_pid) : null, community: comm ? mapShallowCommunity(comm) : null, - created_at: post.created_at.toISOString(), - feeling_id: post.feeling_id, - - is_autopost: !!post.is_autopost, - is_community_private_autopost: !!post.is_community_private_autopost, - is_spoiler: !!post.is_spoiler, - is_app_jumpable: !!post.is_app_jumpable, - - empathy_count: post.empathy_count, - country_id: post.country_id, - language_id: post.language_id, - - mii: post.mii, - mii_face_url: post.mii_face_url, - - pid: post.pid, - platform_id: post.platform_id, - region_id: post.region_id, - parent: post.parent ?? null, - - reply_count: post.reply_count, - verified: post.verified, - - message_to_pid: post.message_to_pid, - removed: post.removed, - removed_by: post.removed_by, - removed_at: post.removed_at?.toISOString(), - removed_reason: post.removed_reason, + author: { + miiName: post.screen_name, + pid: post.pid, + verified: post.verified + }, + mii: { + data: post.mii, + imageUrl: post.mii_face_url + }, + + feelingId: post.feeling_id ?? null, + body: post.body ? post.body : null, + painting: post.painting + ? { + data: post.painting, + imageUrl: post.painting_img ?? '', + imageUrlBig: post.painting_big ?? '' + } + : null, + screenshot: post.screenshot + ? { + imageUrl: post.screenshot, + imageUrlBig: post.screenshot_big ?? '', + imageUrlThumbnail: post.screenshot_thumb ?? '', + length: post.screenshot_length ?? 0, + aspectRatio: post.screenshot_aspect as any + } + : null, + + stats: { + empathyCount: post.empathy_count, + replyCount: post.reply_count + }, + yeahsBy: post.yeahs.map(pid => ({ + pid + })), + + searchKey: post.search_key ?? [], + topicTag: post.topic_tag ?? null, + + isAutopost: !!post.is_autopost, + isCommunityPrivateAutopost: !!post.is_community_private_autopost, + isSpoiler: !!post.is_spoiler, + isAppJumpable: !!post.is_app_jumpable, + + countryId: post.country_id, + languageId: post.language_id, + platformId: post.platform_id ?? null, + regionId: post.region_id ?? null, + titleId: post.title_id ?? null, + appData: post.app_data ?? null, + + moderation: null + }; +} - yeahs: post.yeahs +export function mapPostWithModeration(post: IPost, comm: HydratedCommunityDocument | null, remover: HydratedSettingsDocument | null): PostDto { + return { + ...mapPost(post, comm), + + moderation: { + removed: post.removed + ? { + removedBy: remover ? mapShallowUser(remover) : null, + removedAt: post.removed_at ?? new Date(), + reason: post.removed_reason ?? '' + } + : null + } }; } diff --git a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts index 5b47bcb1..d4205054 100644 --- a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts +++ b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts @@ -2,10 +2,11 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { Community } from '@/models/community'; +import { Settings } from '@/models/settings'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -34,7 +35,19 @@ activityFeedsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); @@ -71,7 +84,19 @@ activityFeedsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); @@ -109,6 +134,18 @@ activityFeedsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts index bb8509c9..c55fc6a7 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts @@ -51,7 +51,10 @@ adminReportsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - const relatedUserIds = rawReports.flatMap(v => [v.reported_by, v.resolved_by]).filter((v): v is number => !!v); + const relatedUserIds = [ + ...rawReports.flatMap(v => [v.reported_by, v.resolved_by]), + ...posts.map(v => v.removed_by) + ].filter((v): v is number => !!v); const users = await Settings.find({ pid: { $in: relatedUserIds } }); let reports = rawReports.map((report) => { diff --git a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts index 321e00c5..e9f3c18a 100644 --- a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { Community } from '@/models/community'; @@ -10,6 +10,7 @@ import { createNewPost, postCreateSchema } from '@/services/internal/utils/posts import { errors } from '@/services/internal/errors'; import { isPostingAllowed } from '@/services/internal/utils/communities'; import { mapSelf } from '@/services/internal/contract/self'; +import { Settings } from '@/models/settings'; export const communityPostsRouter = createInternalApiRouter(); @@ -68,7 +69,19 @@ communityPostsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapFeedPage(posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index 1b3efe97..b9c28d23 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -3,7 +3,7 @@ import { Post } from '@/models/post'; import { errors } from '@/services/internal/errors'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; import { mapResult, resultSchema } from '@/services/internal/contract/result'; import { empathyActionSchema, empathySchema, mapEmpathy } from '@/services/internal/contract/empathy'; @@ -20,6 +20,7 @@ import { Settings } from '@/models/settings'; import { assertCanAccessUser, canAccessUser } from '@/services/internal/utils/user'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const postsRouter = createInternalApiRouter(); @@ -78,7 +79,19 @@ postsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapPage(total, posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapPage(total, mappedPosts); } }); @@ -102,6 +115,11 @@ postsRouter.get({ } const community = await Community.findOne({ olive_community_id: post.community_id }); + let remover: HydratedSettingsDocument | null = null; + if (post.removed_by) { + remover = await Settings.findOne({ pid: post.removed_by }); + } + const poster = await Settings.findOne({ pid: post.pid }); if (!poster) { throw errors.for('not_found'); @@ -109,6 +127,9 @@ postsRouter.get({ // Throws if user isn't visible for some reason assertCanAccessUser(auth, poster); + if (auth?.moderator) { + return mapPostWithModeration(post, community, remover); + } return mapPost(post, community); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/userPosts.ts b/apps/miiverse-api/src/services/internal/routes/userPosts.ts index 642fb4a5..6b4e4faa 100644 --- a/apps/miiverse-api/src/services/internal/routes/userPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/userPosts.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { canAccessUser } from '@/services/internal/utils/user'; @@ -58,6 +58,18 @@ userPostsRouter.get({ const communityIds = posts.map(v => v.community_id); const communities = await Community.find({ olive_community_id: { $in: communityIds } }); - return mapPage(total, posts.map(p => mapPost(p, communities.find(v => v.olive_community_id === p.community_id) ?? null))); + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapPage(total, mappedPosts); } }); From 267744c63a7db9685bf46bd345889747f7abd86b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 24 May 2026 11:56:26 +0200 Subject: [PATCH 027/103] feat: update juxt-ui with new post DTO --- .../juxt-web/routes/console/posts.tsx | 57 +++++++++-------- .../src/services/juxt-web/views/ctr/post.tsx | 54 ++++++++-------- .../juxt-web/views/ctr/postPageView.tsx | 4 +- .../services/juxt-web/views/portal/post.tsx | 62 +++++++++--------- .../juxt-web/views/portal/postPageView.tsx | 9 +-- .../moderate-user/moderateUserPostView.tsx | 63 ++++++++++--------- .../src/services/juxt-web/views/web/post.tsx | 43 +++++++------ .../juxt-web/views/web/postPageView.tsx | 12 ++-- 8 files changed, 159 insertions(+), 145 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index eabee6ef..d29ce1c0 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -63,19 +63,19 @@ postsRouter.get('/:post_id/oembed.json', async function (req, res) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: post.community_id }); - const postPNID = await getUserAccountData(post.pid); + const { data: community } = await req.api.communities.get({ id: post.community.id }); + const postPNID = await getUserAccountData(post.author.pid); let img = {}; - if (post.painting !== '') { + if (post.painting) { img = { - thumbnail_url: `${res.locals.cdnURL}/paintings/${post.pid}/${post.id}.png`, + thumbnail_url: `${res.locals.cdnURL}/paintings/${post.author.pid}/${post.id}.png`, thumbnail_width: 320, thumbnail_height: 120 }; - } else if (post.screenshot_thumb !== '') { + } else if (post.screenshot) { img = { - thumbnail_url: `${res.locals.cdnURL}${post.screenshot_thumb}` + thumbnail_url: `${res.locals.cdnURL}${post.screenshot.imageUrlThumbnail}` }; } else { img = { @@ -88,10 +88,10 @@ postsRouter.get('/:post_id/oembed.json', async function (req, res) { const doc = { type: 'link', version: '1.0', - title: `${post.screen_name} (@${postPNID.username}) - ${community?.name}`, + title: `${post.author.miiName} (@${postPNID.username}) - ${community?.name}`, description: post.body, - author_name: post.screen_name, - author_url: 'https://juxt.pretendo.network/users/show?pid=' + post.pid, + author_name: post.author.miiName, + author_url: 'https://juxt.pretendo.network/users/show?pid=' + post.author.pid, provider_name: 'Juxtaposition - Pretendo Network', provider_url: `https://juxt.pretendo.network`, ...img @@ -111,17 +111,17 @@ postsRouter.post('/empathy', yeahLimit, async function (req, res) { return res.sendStatus(404); } - const existingEmpathy = post.yeahs.indexOf(auth().pid) !== -1; + const existingEmpathy = post.yeahsBy.some(v => v.pid === auth().pid); const action: EmpathyActionEnum = !existingEmpathy ? 'add' : 'remove'; const { data: result } = await req.api.posts.changeEmpathy({ post_id: post.id, action }); if (result === null) { - res.send({ status: 423, id: post.id, count: post.empathy_count }); + res.send({ status: 423, id: post.id, count: post.stats.empathyCount }); return; } res.send({ status: 200, id: result.post_id, count: result.empathy_count }); - await redisRemove(`${post.pid}_user_page_posts`); + await redisRemove(`${post.author.pid}_user_page_posts`); }); postsRouter.post('/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 }]), async function (req, res) { @@ -140,21 +140,25 @@ postsRouter.get('/:post_id', async function (req, res) { if (!post) { return res.redirect('/404'); } - if (post.parent) { - const { data: parent } = await req.api.posts.get({ post_id: post.parent }); + if (post.parentId) { + const { data: parent } = await req.api.posts.get({ post_id: post.parentId }); if (!parent) { return res.redirect('/404'); } return res.redirect(`/posts/${parent.id}`); } - const { data: community } = await req.api.communities.get({ id: post.community_id }); + + if (!post.community) { + return res.redirect('/404'); + } + const { data: community } = await req.api.communities.get({ id: post.community.id }); if (!community) { return res.redirect('/404'); } // increase limit for post replies since there's no pagination yet const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort: 'oldest', limit: 500 }))?.data.items ?? []; - const postPNID = await getUserAccountData(post.pid); + const postPNID = await getUserAccountData(post.author.pid); const canPost = !!self && isPostingAllowed(community, self, post); const props: PostPageViewProps = { @@ -193,12 +197,12 @@ postsRouter.delete('/:post_id', async function (req, res) { } res.statusCode = 200; - if (post.parent) { - res.send(`/posts/${post.parent}`); + if (post.parentId) { + res.send(`/posts/${post.parentId}`); } else { res.send('/users/me'); } - await redisRemove(`${post.pid}_user_page_posts`); + await redisRemove(`${post.author.pid}_user_page_posts`); }); postsRouter.post('/:post_id/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 }]), async function (req, res) { @@ -220,7 +224,10 @@ postsRouter.get('/:post_id/create', async function (req, res) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: parent.community_id }); + if (!parent.community) { + return res.sendStatus(404); + } + const { data: community } = await req.api.communities.get({ id: parent.community.id }); if (!community) { return res.sendStatus(404); } @@ -228,8 +235,8 @@ postsRouter.get('/:post_id/create', async function (req, res) { const shotMode = getShotMode(community, auth().paramPackData); const props: NewPostViewProps = { - id: parent.community_id, - name: parent.screen_name, + id: parent.community.id, + name: parent.author.miiName, url: `/posts/${parent.id}/new`, show: 'post', shotMode, @@ -365,10 +372,10 @@ async function newPost(req: Request, res: Response): Promise { throw new Error('Null newPostResult'); } - if (newPostResult.parent) { - res.redirect('/posts/' + newPostResult.parent.toString()); + if (newPostResult.parentId) { + res.redirect('/posts/' + newPostResult.parentId.toString()); return; } - res.redirect('/titles/' + newPostResult.community_id + '/new'); + res.redirect('/titles/' + newPostResult.community?.id + '/new'); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx index 502931e1..00e434f6 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx @@ -12,18 +12,18 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { const url = useUrl(); const post = props.post; if (!post.screenshot) { - return <>; + return null; } - if (post.screenshot_aspect && post.screenshot_thumb) { + if (post.screenshot.aspectRatio && post.screenshot.imageUrlThumbnail) { // modern type return ( ); } else { @@ -31,7 +31,7 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { return ( ); } @@ -42,7 +42,7 @@ export function CtrPostView(props: PostViewProps): ReactNode { const user = useUser(); const post = props.post; - const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; + const hasYeahed = post.yeahsBy.some(v => v.pid === user.pid); // TODO implement moderator removed post logic return ( @@ -50,30 +50,30 @@ export function CtrPostView(props: PostViewProps): ReactNode { id={`post-${post.id}`} className={cx('post', { reply: props.isReply, - spoiler: post.is_spoiler + spoiler: post.isSpoiler })} > - +
    - {post.screen_name} + {post.author.miiName} {' '} {'- '} - {moment(post.created_at).fromNow()} + {moment(post.createdAt).fromNow()} - { post.topic_tag + { post.topicTag ? ( - + - {post.topic_tag} + {post.topicTag} ) @@ -82,16 +82,16 @@ export function CtrPostView(props: PostViewProps): ReactNode { { !props.isReply ? ( - + - + {post.community.name} ) : null} - { post.is_spoiler + { post.isSpoiler ? (
    @@ -100,15 +100,15 @@ export function CtrPostView(props: PostViewProps): ReactNode { : null }
    - {post.body !== '' + {post.body ? (

    {post.body}

    ) : null} - {post.painting !== '' + {post.painting ? ( - + ) : null} {/* TODO add post.url back */} @@ -116,20 +116,20 @@ export function CtrPostView(props: PostViewProps): ReactNode {
    - { props.isMainPost && post.yeahs.length > 0 + { props.isMainPost && post.yeahsBy.length > 0 ? ( <>
    { post.yeahs.length } + count: { post.yeahsBy.length } }} />
    - {post.yeahs.slice(0, 10).map(pid => ( + {post.yeahsBy.slice(0, 10).map(({ pid }) => ( ))}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx index 6d587cb7..b41391c5 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx @@ -21,7 +21,7 @@ export function CtrPostPageView(props: PostPageViewProps): ReactNode { header={header} data-toolbar-mode="normal" > - {post.screen_name} + {post.author.miiName} {props.canPost @@ -35,7 +35,7 @@ export function CtrPostPageView(props: PostPageViewProps): ReactNode { ) : null } - {post.pid === user.pid + {post.author.pid === user.pid ? ( ; + return null; } - if (post.screenshot_aspect) { + if (post.screenshot.aspectRatio) { // modern type return ( ); } else { @@ -31,7 +31,7 @@ function PortalPostScreenshot(props: PostScreenshotProps): ReactNode { return ( ); } @@ -41,18 +41,18 @@ export function PortalPostView(props: PostViewProps): ReactNode { const url = useUrl(); const user = useUser(); const post = props.post; - const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; + const hasYeahed = post.yeahsBy.some(v => v.pid === user.pid); const isModerator = user.perms.moderator; // TODO implement moderator removed post logic const content = ( <> -
    - + +
    - {post.screen_name} + {post.author.miiName} {' '} {'- '} - {moment(post.created_at).fromNow()} + {moment(post.createdAt).fromNow()} - {post.topic_tag + {post.topicTag ? ( - + {/* TODO this has been modified due to inbalanced tags */} - {post.topic_tag} + {post.topicTag} ) : null } @@ -83,16 +83,16 @@ export function PortalPostView(props: PostViewProps): ReactNode { { !props.isReply ? ( - + - + {post.community.name} ) : null} - { post.is_spoiler + { post.isSpoiler ? (
    @@ -104,9 +104,9 @@ export function PortalPostView(props: PostViewProps): ReactNode { className="post-content" data-href={!props.isReply ? `/posts/${post.id}` : undefined} > - {post.body !== '' ?

    {post.body}

    : null} + {post.body ?

    {post.body}

    : null} - {post.painting !== '' ? : null} + {post.painting ? : null} {/* TODO add post.url back */}
    @@ -121,14 +121,14 @@ export function PortalPostView(props: PostViewProps): ReactNode { {' '} - { props.isReply && post.pid !== user.pid && !isModerator + { props.isReply && post.author.pid !== user.pid && !isModerator ? (
    ) : null} - { props.isReply && (post.pid === user.pid || isModerator) + { props.isReply && (post.author.pid === user.pid || isModerator) ? (
    @@ -139,10 +139,10 @@ export function PortalPostView(props: PostViewProps): ReactNode { ? ( <> {' '} - {post.empathy_count} + {post.stats.empathyCount} { !props.isReply ? ( - {post.reply_count} + {post.stats.replyCount} ) : null} {' '} @@ -153,22 +153,22 @@ export function PortalPostView(props: PostViewProps): ReactNode {
    - { props.isMainPost && post.yeahs.length > 0 + { props.isMainPost && post.yeahsBy.length > 0 ? ( <>
    {post.empathy_count} + count: {post.stats.empathyCount} }} />
    @@ -183,10 +183,10 @@ export function PortalPostView(props: PostViewProps): ReactNode { id={`post-${post.id}`} className={cx('post', { reply: props.isReply, - spoiler: post.is_spoiler + spoiler: post.isSpoiler })} > - {post.removed + {post.moderation?.removed ? (

    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx index e93c4281..e9e88496 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx @@ -9,7 +9,8 @@ import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageVi export function PortalPostPageView(props: PostPageViewProps): ReactNode { const user = useUser(); const { post } = props; - const pageTitle = !post.removed ? post.screen_name : 'Removed Post'; + const removedData = post.moderation?.removed; + const pageTitle = !removedData ? post.author.miiName : 'Removed Post'; return ( @@ -17,7 +18,7 @@ export function PortalPostPageView(props: PostPageViewProps): ReactNode {
    ); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index 9e81c5b6..36aefcb3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -15,10 +15,10 @@ export function WebPostScreenshot(props: PostScreenshotProps): ReactNode { const url = useUrl(); const post = props.post; if (!post.screenshot) { - return <>; + return null; } - return ; + return ; } export type PostViewProps = { @@ -33,12 +33,11 @@ export function WebPostView(props: PostViewProps): ReactNode { const user = useUser(); const post = props.post; const isModerator = user.perms.moderator; - const canAccessContent = !post.removed || isModerator; - const yeahed = !!props.userContent && !!post.yeahs && post.yeahs.includes(user.pid); + const yeahed = post.yeahsBy.some(v => v.pid === user.pid); let removedPostPart = null; - if (post.removed) { + if (post.moderation?.removed) { removedPostPart = (

    @@ -49,34 +48,34 @@ export function WebPostView(props: PostViewProps): ReactNode { const contentPart = ( <>
    - +

    - {post.screen_name} + {post.author.miiName}

    - { post.verified + { post.author.verified ? ( ) : null}

    - {moment(post.created_at).fromNow()} + {moment(post.createdAt).fromNow()} {' - '} - {post.community.name} + {post.community ? {post.community.name} : null }

    - { post.is_spoiler + { post.isSpoiler ? (
    @@ -111,7 +110,7 @@ export function WebPostView(props: PostViewProps): ReactNode { aria-pressed={yeahed} > -

    {post.empathy_count}

    +

    {post.stats.empathyCount}

    {/* Reply "button" */} @@ -121,14 +120,14 @@ export function WebPostView(props: PostViewProps): ReactNode { role="button" > -

    {post.reply_count}

    +

    {post.stats.replyCount}

    {/* Hamburger menu */}
    diff --git a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss index eea0c4fd..66ff9003 100644 --- a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss +++ b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss @@ -267,15 +267,7 @@ menu.tab-header.user-page li:last-child { .post header > a, .tags { display: inline-block; - position: absolute -} - -.post header > a { - width: 258px; - vertical-align: middle; - overflow: clip; - left: 0; - top: 17px + position: absolute; } .tags { diff --git a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css index b444fd61..541f88b6 100644 --- a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css +++ b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css @@ -512,12 +512,13 @@ body { } header svg { fill:#a362d8; - -webkit-transform:translate(10px,10px); - margin-left:-10px + -webkit-transform:translate(0px,10px) scale(0.8); + margin-left: 20px; + margin-right: 5px; } .tags, header svg { - color:#a362d8 + color:#a362d8; } .post-list .post .no-play, .post-list .post .screen-name { From ff78a21fc8f48db8a0fdc2054087aeac7abc457b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 29 May 2026 20:58:18 +0200 Subject: [PATCH 040/103] fix: add fallback for notification users --- .../src/services/juxt-web/views/web/notificationListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 42a61c1d..4efb64ac 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -24,7 +24,7 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { const url = useUrl(); const notif = props.notification; if (notif.type === 'follow') { - const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? 'Nobody'}; let i18nKey: TranslationKey = 'notifications.new_follower/one'; if (notif.users.length === 2) { From 19970f8863d2290270da49e5f00ded941d704d10 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:43:15 -0700 Subject: [PATCH 041/103] web: Bandwidth Alert Image No Longer Has a Static Background --- .../webfiles/web/images/bandwidthalert.png | Bin 26917 -> 44590 bytes 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 apps/juxtaposition-ui/webfiles/web/images/bandwidthalert.png diff --git a/apps/juxtaposition-ui/webfiles/web/images/bandwidthalert.png b/apps/juxtaposition-ui/webfiles/web/images/bandwidthalert.png old mode 100644 new mode 100755 index 4ba7ab6b98b622dca53a250bbbf9f6ef13e41d82..b8317ee73dffbb4a38ccd2e060b0c9ffb5ead008 GIT binary patch literal 44590 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 From 4e1f693eab161ddb4a456a103054fd90a9855850 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:59:30 -0700 Subject: [PATCH 042/103] style(web): Center Bandwidth Icon on Notifications Page --- apps/juxtaposition-ui/webfiles/web/css/web.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/juxtaposition-ui/webfiles/web/css/web.scss b/apps/juxtaposition-ui/webfiles/web/css/web.scss index f604821a..e3d4e163 100644 --- a/apps/juxtaposition-ui/webfiles/web/css/web.scss +++ b/apps/juxtaposition-ui/webfiles/web/css/web.scss @@ -261,6 +261,10 @@ header>.selected svg { display: block; } +.icon-container.notify { + margin: auto; +} + .user-icon.verified { border: solid 4px var(--btn); width: 60px; From 9082a46d21f73875fefafd64aef93e2bd30c5013 Mon Sep 17 00:00:00 2001 From: Nervi05 Date: Thu, 25 Jun 2026 10:40:34 +0200 Subject: [PATCH 043/103] locales(update): Updated German locale --- .../src/assets/locales/de.json | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/de.json b/apps/juxtaposition-ui/src/assets/locales/de.json index 038fda7b..cf764e4d 100644 --- a/apps/juxtaposition-ui/src/assets/locales/de.json +++ b/apps/juxtaposition-ui/src/assets/locales/de.json @@ -26,7 +26,7 @@ }, "all_communities": { "text": "Alle Communitys", - "popular_places": "Beliebte Orte", + "popular_places": "Beliebte Communities", "new_communities": "Neue Communitys" }, "community": { @@ -39,7 +39,8 @@ "tags_not_applicable": "nicht verfügbar", "related": "Ähnliche Communities", "related_to": "Ähnliche Communities wie {{community}}", - "closed": "In dieser Community können keine neuen Beiträge erstellt werden." + "closed": "In dieser Community können keine neuen Beiträge erstellt werden.", + "related_short": "Verwandte" }, "user_page": { "country": "Land", @@ -55,7 +56,12 @@ "settings": "Einstellungen", "deleted": "Gelöschter Nutzer", "banned": "Gesperrter Nutzer", - "friend_requests": "Anfragen" + "friend_requests": "Anfragen", + "not_found": "Nutzer existiert nicht", + "tester_tag": "Tester*In", + "supporter_tag": "Unterstützer*In", + "moderator_tag": "Moderator*In", + "developer_tag": "Entwickler*In" }, "user_settings": { "profile_settings": "Profileinstellungen", @@ -64,7 +70,8 @@ "show_game": "Spielerfahrung im Profil anzeigen", "save_action": "Einstellungen speichern", "gdpr_download": "Nutzerdaten herunterladen", - "gdpr_download_action": "Herunterladen" + "gdpr_download_action": "Herunterladen", + "show_profile": "Zeige Profil Gästen" }, "activity_feed": { "empty": "Es ist ganz schön leer hier. Versuche, jemandem zu folgen!" @@ -85,14 +92,15 @@ "no_screenshot": "Kein Screenshot", "spoiler_label": "Spoiler", "painting_close": "Abbrechen", - "painting_submit": "OK" + "painting_submit": "OK", + "automod_error": "Dein Beitrag beinhaltet Text, welcher nicht auf Juxtaposition erlaubt ist. Für mehr Informationen besuche bitte https://preten.do/juxt-rules" }, "setup": { "welcome": "Willkommen bei Juxtaposition!", "welcome_text": "Juxt ist eine Gaming-Community, die Menschen aus der ganzen Welt mit Mii-Charakteren verbindet. Nutze Juxt, um deine Spielerlebnisse zu teilen und Leute aus der ganzen Welt zu treffen.", "beta": "Beta-Haftungsausschluss", "beta_text": { - "first": "Du bist dabei, die erste öffentliche Beta von Juxt auszuprobieren. Das heißt, es liegt noch viel in der Luft, vieles kann sich jederzeit ändern.", + "first": "Willkommen zur öffentlichen Beta von Pretendo Network! Seit der ersten öffentlichen Beta hat sich einiges geändert, Juxt ist immer noch in aktiver Entwicklung und es kann sich einiges in kurzer Zeit ändern.", "second": "Dies kann und wird eine vollständige Löschung der Datenbank am Ende oder während des Beta-Zeitraums beinhalten.", "third": "Die Website, ihre Software und alle darauf enthaltenen Inhalte werden „wie besehen“ und „wie verfügbar“ bereitgestellt. Das Pretendo Network gibt keine ausdrücklichen oder stillschweigenden Garantien hinsichtlich der Eignung oder Nutzbarkeit der Website, ihrer Software oder ihres Inhalts." }, @@ -144,7 +152,7 @@ "title": "Error {{code}}", "heading": "Error {{code}}: {{message}}", "message": "Hoppla! Die gesuchte Seite konnte leider nicht gefunden werden.Überprüfe bitte deinen Link oder versuche es später noch einmal.", - "no_access": "Sie sind nicht berechtigt, auf diese Anwendung zuzugreifen ({{code}}).", + "no_access": "Sie sind nicht berechtigt auf diese Anwendung zuzugreifen ({{code}})", "message_web": "Serverstatus anzeigen.Oder zurück zur Startseite.", "error_details": "Anfrage-ID: {{id}}" }, @@ -155,7 +163,7 @@ "title": "Beitrag von {{username}}", "heading": "Beitrag", "yeahs_count/one": " Person hat diesem Beitrag ein „Yeah“ gegeben.", - "yeahs_count/multiple": " Person hat diesem Beitrag ein „Yeah“ gegeben.", + "yeahs_count/multiple": " Personen haben diesem Beitrag ein „Yeah“ gegeben.", "show_spoiler": "Spoiler anzeigen", "reply_post": "Antworten", "report_post": "Beitrag melden", @@ -165,7 +173,7 @@ }, "reporting": { "title": "Beitrag melden", - "submit": "Bericht senden", + "submit": "Meldung senden", "description": "Du bist dabei, einen Beitrag zu melden, dessen Inhalt gegen den Juxtaposition-Verhaltenskodex verstößt. Diese Meldung wird an die Juxtaposition-Administratoren von Pretendo gesendet und nicht an den Ersteller des Beitrags.", "label": "Grund:", "additional_info_placeholder": "Zusätzliche Kommentare oder Informationen eingeben", @@ -178,7 +186,8 @@ "reason_nsfw": "Sexuell explizit", "reason_piracy": "Piraterie", "reason_inappropiate_ingame": "Unangemessenes Verhalten im Spiel", - "reason_missing_images": "Fehlende Bilder" + "reason_missing_images": "Fehlende Bilder", + "reason_others": "Andere Gründe" }, "moderation": { "title": "Moderation", From ea796e04b946bf014095e0d36f76acce1b2bc604 Mon Sep 17 00:00:00 2001 From: wanhy <2586128385@qq.com> Date: Sat, 27 Jun 2026 13:03:13 +0200 Subject: [PATCH 044/103] locales(update): Updated Chinese (Simplified Han script) locale --- apps/juxtaposition-ui/src/assets/locales/zh.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/zh.json b/apps/juxtaposition-ui/src/assets/locales/zh.json index 8d818457..12e91e87 100644 --- a/apps/juxtaposition-ui/src/assets/locales/zh.json +++ b/apps/juxtaposition-ui/src/assets/locales/zh.json @@ -57,7 +57,8 @@ "deleted": "已删除用户", "banned": "已禁止用户", "friend_requests": "请求", - "not_found": "用户不存在" + "not_found": "用户不存在", + "tester_tag": "测试用户" }, "user_settings": { "profile_settings": "个人资料设置", From de90ca0fb61db0849d5178afd4c0492ced77fcb1 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 28 Jun 2026 20:54:21 +0200 Subject: [PATCH 045/103] fix: make subcommunity list the same as normal community list --- .../src/services/juxt-web/views/web/subCommunityView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx index 2392db6c..58c2ede2 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx @@ -13,7 +13,7 @@ export function WebSubCommunityView(props: SubCommunityViewProps): ReactNode {
    - +
  • + { isModerator && post.is_spoiler + ? ( +
  • + + {' '} + +
  • + ) + : null} + { isModerator && !post.is_spoiler + ? ( +
  • + + {' '} + +
  • + ) + : null}
    diff --git a/apps/juxtaposition-ui/webfiles/web/js/api.ts b/apps/juxtaposition-ui/webfiles/web/js/api.ts index e7cfd1a1..74712614 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/api.ts +++ b/apps/juxtaposition-ui/webfiles/web/js/api.ts @@ -52,3 +52,55 @@ export function deletePostById(id: string, reason: string | null, cb: (result: D return cb({ status: 200, nextUrl }); }); } + +export type UnspoilerPostResponse = { + success: boolean; +}; + +export function unspoilerPostById(id: string, cb: () => void): void { + POST(`/posts/${id}/edit/unspoiler`, '', (xhr) => { + if (xhr.status !== 200) { + return cb(); + } + let res: UnspoilerPostResponse; + + try { + res = JSON.parse(xhr.responseText); + } catch (e) { + console.error(e); + return cb(); + } + + if (!res.success) { + return cb(); + } + + cb(); + }); +} + +export type SpoilerPostResponse = { + success: boolean; +}; + +export function spoilerPostById(id: string, cb: () => void): void { + POST(`/posts/${id}/edit/spoiler`, '', (xhr) => { + if (xhr.status !== 200) { + return cb(); + } + let res: SpoilerPostResponse; + + try { + res = JSON.parse(xhr.responseText); + } catch (e) { + console.error(e); + return cb(); + } + + if (!res.success) { + return cb(); + } + + cb(); + }); +} diff --git a/apps/juxtaposition-ui/webfiles/web/js/web.js b/apps/juxtaposition-ui/webfiles/web/js/web.js index c6cc5af0..a34b1346 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/web.js +++ b/apps/juxtaposition-ui/webfiles/web/js/web.js @@ -1,7 +1,7 @@ import { popupItemCb, setupPopup } from './menus'; import { initReportForm, reportPost } from './reports'; import { POST, GET } from './xhr'; -import { deletePostById } from './api'; +import { deletePostById, spoilerPostById, unspoilerPostById } from './api'; import { initYeahButton } from './post'; import { initSearchForm } from './components/ui/WebSearchForm'; @@ -97,6 +97,18 @@ function initPopupMenus() { popupItemCb(menu.querySelector('[data-action="copy"]'), (_item, _ev) => { copyToClipboard(`${window.location.origin}/posts/${post}`); }); + popupItemCb(menu.querySelector('[data-action="spoiler"]'), (_item, _ev) => { + spoilerPostById(post, () => { + alert('Spoiler has been successfully added to the post'); + window.location.reload(); + }); + }); + popupItemCb(menu.querySelector('[data-action="unspoiler"]'), (_item, _ev) => { + unspoilerPostById(post, () => { + alert('Spoiler has been successfully removed from post'); + window.location.reload(); + }); + }); }); } From 484ed20a39b8a87eb46923c25d987287c9b97b7e Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 28 Jun 2026 22:12:59 +0200 Subject: [PATCH 049/103] feat: add backend for unspoilering/spoilering posts --- .../juxt-web/routes/console/posts.tsx | 42 +++++++++++++++++++ .../src/services/internal/routes/posts.ts | 34 +++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index ca7f7406..5d983369 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -306,6 +306,48 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { return res.redirect(`/posts/${post.id}`); }); +postsRouter.post('/:post_id/edit/spoiler', async function (req, res) { + const { params } = parseReq(req, { + params: z.object({ + post_id: z.string() + }) + }); + + const { data: post } = await req.api.posts.get({ post_id: params.post_id }); + if (!post) { + return res.json({ + success: false + }); + } + + await req.api.posts.changeSpoiler({ post_id: params.post_id, is_spoiler: true }); + + res.json({ + success: true + }); +}); + +postsRouter.post('/:post_id/edit/unspoiler', async function (req, res) { + const { params } = parseReq(req, { + params: z.object({ + post_id: z.string() + }) + }); + + const { data: post } = await req.api.posts.get({ post_id: params.post_id }); + if (!post) { + return res.json({ + success: false + }); + } + + await req.api.posts.changeSpoiler({ post_id: params.post_id, is_spoiler: false }); + + res.json({ + success: true + }); +}); + async function newPost(req: Request, res: Response): Promise { const { params, body, files, auth, hasAuth } = parseReq(req, { params: z.object({ diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index 7155e9a4..f5e5b711 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -212,3 +212,37 @@ postsRouter.post({ return mapEmpathy(body.action, post); } }); + +postsRouter.post({ + path: '/posts/:post_id/edit/spoiler', + name: 'posts.changeSpoiler', + description: 'Change spoiler for post', + guard: guards.moderator, + schema: { + params: postIdObjSchema, + body: z.object({ + is_spoiler: z.boolean() + }), + response: resultSchema + }, + async handler({ params, body, auth }) { + const post = await Post.findOne({ + id: params.post_id, + message_to_pid: null, // messages aren't really posts + ...filterRemovedPosts(auth) + }); + if (!post) { + throw errors.for('not_found'); + } + + await Post.findOneAndUpdate({ + _id: post._id + }, { + $set: { + is_spoiler: body.is_spoiler + } + }); + + return mapResult('success'); + } +}); From 498ef4e488179a85793074edf5d09945eded9b95 Mon Sep 17 00:00:00 2001 From: KorvusDev Date: Sun, 28 Jun 2026 14:31:36 +0200 Subject: [PATCH 050/103] locales(update): Updated Catalan locale --- .../src/assets/locales/ca.json | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ca.json b/apps/juxtaposition-ui/src/assets/locales/ca.json index 4dc7bc07..3c09a66a 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ca.json +++ b/apps/juxtaposition-ui/src/assets/locales/ca.json @@ -14,7 +14,14 @@ "user_page": "Pàgina d'usuari", "go_back": "Torna", "yeahs": "Mola", - "search": "Cerca..." + "search": "Cerca...", + "my_feed": "Les meves Publicacions", + "global_feed": "Publicacions Globals", + "global_feed_short": "Global", + "people_feed": "Seguint", + "people_feed_short": "Seguits", + "friend_requests": "Sol·licituds d'Amistat", + "updates": "Actualitzacions" }, "notifications": { "none": "No hi ha notificacions." @@ -35,7 +42,10 @@ "tags": "Etiquetes", "followers": "Seguidors", "recent": "Publicacions recents", - "popular": "Publicacions populars" + "popular": "Publicacions populars", + "followers_count": "{{count}} seguidors", + "tags_not_applicable": "N/A", + "related": "Comunitats relacionades" }, "user_settings": { "show_game": "Mostra la experiència de joc al perfil", @@ -103,9 +113,17 @@ "forgot_password": "¿Has oblidat la teva contrasenya?", "no_account": "¿No tens cap compte?", "login_action": "Iniciar sessió", - "no_account_setup": "La creació de comptes només estarà disponible quan hagis vinculat una Wii U o 3DS." + "no_account_setup": "La creació de comptes només estarà disponible quan hagis vinculat una Wii U o 3DS.", + "title": "Inicia la sessió per juxtaposició", + "heading": "Registre per juxtaposició", + "sub_title": "Introduïu les dades del vostre compte a continuació" }, "error": { - "title": "Codi d'error: {{code}}" + "title": "Codi d'error: {{code}}", + "heading": "Error {{code}}: {{message}}", + "message": "Ups! Sembla que no hem trobat la pàgina que busques.Comprova l'enllaç o torna-ho a provar més tard", + "no_access": "No estas autoritzat a accedir a aquesta aplicació ({{code}})", + "message_web": "Veure l'estat actual del servidor.O bé, torna a la pàgina d'inici", + "error_details": "ID de sol·licitud: {{id}}" } } From ae8732b6bad4768377e7c897d0f013b001de5da3 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 30 Jun 2026 20:59:08 +0200 Subject: [PATCH 051/103] Rename migration so it's nicely numbered --- migrations/{6-fix-followers.js => 7-fix-followers.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/{6-fix-followers.js => 7-fix-followers.js} (94%) diff --git a/migrations/6-fix-followers.js b/migrations/7-fix-followers.js similarity index 94% rename from migrations/6-fix-followers.js rename to migrations/7-fix-followers.js index 34d31737..cfa4b944 100644 --- a/migrations/6-fix-followers.js +++ b/migrations/7-fix-followers.js @@ -5,7 +5,7 @@ // That data can be fixed once it's moved to a relational database // --- // This script is intended to run in mongosh: -// $ mongosh mongodb://localhost:27017/mydb 6-fix-followers.js +// $ mongosh mongodb://localhost:27017/mydb 7-fix-followers.js const user = db.contents.findOne({ }); From ba6b969efe264e5a8e37864a4e24c6f3acbf0b22 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 30 Jun 2026 21:53:48 +0200 Subject: [PATCH 052/103] feat: allow posting on web --- .../juxt-web/routes/console/communities.tsx | 6 ++- .../juxt-web/routes/console/posts.tsx | 6 ++- .../services/juxt-web/routes/permissions.ts | 4 +- .../juxt-web/views/web/newPostView.tsx | 40 ++++++++++--------- .../webfiles/web/css/web.scss | 8 ++-- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx index 02d02de9..3819b7de 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx @@ -22,12 +22,13 @@ import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permis import { Community } from '@/models/communities'; import { getAllFromList } from '@/api/helpers'; import { WebSubCommunityView } from '@/services/juxt-web/views/web/subCommunityView'; +import { WebNewPostPage } from '@/services/juxt-web/views/web/newPostView'; +import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; import type { PostListViewProps } from '@/services/juxt-web/views/web/postList'; import type { CommunityViewProps } from '@/services/juxt-web/views/web/communityView'; import type { SubCommunityViewProps } from '@/services/juxt-web/views/portal/subCommunityView'; import type { CommunityListViewProps, CommunityOverviewViewProps } from '@/services/juxt-web/views/web/communityListView'; import type { FollowAction, FollowActionEnum, Post } from '@/api/generated'; -import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; const upload = multer({ dest: 'uploads/' }); export const communitiesRouter = express.Router(); @@ -158,7 +159,8 @@ communitiesRouter.get('/:communityID/create', async function (req, res) { }; res.jsxForDirectory({ ctr: , - portal: + portal: , + web: }); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index ca7f7406..90140e8a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -20,11 +20,12 @@ import { PortalReportPostPage } from '@/services/juxt-web/views/portal/reportPos import { CtrReportPostPage } from '@/services/juxt-web/views/ctr/reportPostView'; import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; import { AutomodRule } from '@/models/automodRules'; +import { WebNewPostPage } from '@/services/juxt-web/views/web/newPostView'; import type { Request, Response } from 'express'; +import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; import type { PaintingUrls } from '@/images'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; import type { EmpathyActionEnum } from '@/api/generated'; -import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; const storage = multer.memoryStorage(); const upload = multer({ storage }); export const postsRouter = express.Router(); @@ -246,7 +247,8 @@ postsRouter.get('/:post_id/create', async function (req, res) { }; res.jsxForDirectory({ ctr: , - portal: + portal: , + web: }); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts index 7eb74412..89b0a9a4 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts @@ -29,8 +29,8 @@ export function isPostingAllowed(community: Community, user: Self, parentPost: I return isReply ? isOpenCommunity : isPublicPostableCommunity; } -export function getShotMode(community: Community, pack: ParamPack | null): CommunityShotMode { - if (pack === null) { +export function getShotMode(community: Community, pack: ParamPack | null | undefined): CommunityShotMode { + if (!pack) { return 'block'; } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx index f8354412..ba0c9e30 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx @@ -1,6 +1,8 @@ import { T } from '@/services/juxt-web/views/common/components/T'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; +import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; +import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import type { ReactNode } from 'react'; import type { CommunityShotMode } from '@/models/communities'; import type { Community } from '@/api/generated'; @@ -65,8 +67,8 @@ export function WebNewPostView(props: NewPostViewProps): ReactNode { const url = useUrl(); const user = useUser(); return ( -
    -
    +
    + {props.errorText ?

    {props.errorText}

    : null}
    @@ -89,19 +91,8 @@ export function WebNewPostView(props: NewPostViewProps): ReactNode {
    - -
  • - -
  • -
  • - -
  • -
    + -
    - - -
    @@ -127,3 +113,19 @@ export function WebNewPostView(props: NewPostViewProps): ReactNode {
    ); } + +export function WebNewPostPage(props: NewPostViewProps): ReactNode { + const cache = useCache(); + const name = props.name ?? cache.getUserName(props.pid ?? 0); + + return ( + +

    + +

    + + + +
    + ); +} diff --git a/apps/juxtaposition-ui/webfiles/web/css/web.scss b/apps/juxtaposition-ui/webfiles/web/css/web.scss index e281a7b3..cb061518 100644 --- a/apps/juxtaposition-ui/webfiles/web/css/web.scss +++ b/apps/juxtaposition-ui/webfiles/web/css/web.scss @@ -749,7 +749,7 @@ button.report { align-items: center; width: 100%; justify-content: center; - border-radius: 0 20px 20px 0; + border-radius: 10px; } .add-post-page .textarea-text { @@ -759,7 +759,7 @@ button.report { padding: 1ch; border: none; resize: none; - border-radius: 0 20px 20px 0; + border-radius: 10px; background: #f9f5fb; } @@ -2060,5 +2060,5 @@ button.report { } .no-reports-message { - margin: 20px auto auto auto; - } \ No newline at end of file + margin: 20px auto auto auto; +} \ No newline at end of file From 07a5d06e32146d5dd0265dcc732ce3b4b332ef46 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 30 Jun 2026 22:26:39 +0200 Subject: [PATCH 053/103] feat: add reply sorting & add posting buttons on web for moderators --- .../src/assets/locales/en.json | 4 +- .../juxt-web/routes/console/posts.tsx | 13 +++++-- .../juxt-web/views/web/communityView.tsx | 7 ++++ .../juxt-web/views/web/newPostView.tsx | 12 +++++- .../juxt-web/views/web/postPageView.tsx | 27 +++++++++++++ .../webfiles/web/css/web.scss | 38 +++++++++++++++++++ 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index 06429d1d..a8753a1a 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -127,7 +127,9 @@ "report_post": "Report post", "delete_post": "Delete post", "removed": "Post has been removed.", - "copy_link": "Copy link" + "copy_link": "Copy link", + "sort_newest_first": "Newest posts", + "sort_oldest_first": "Oldest posts" }, "setup": { "title": "Juxtaposition Setup", diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 90140e8a..3ac7bc25 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -25,7 +25,7 @@ import type { Request, Response } from 'express'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; import type { PaintingUrls } from '@/images'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; -import type { EmpathyActionEnum } from '@/api/generated'; +import type { EmpathyActionEnum, StandardSortEnum } from '@/api/generated'; const storage = multer.memoryStorage(); const upload = multer({ storage }); export const postsRouter = express.Router(); @@ -138,9 +138,12 @@ postsRouter.post('/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 } }); postsRouter.get('/:post_id', async function (req, res) { - const { params, hasAuth, auth } = parseReq(req, { + const { params, query, hasAuth, auth } = parseReq(req, { params: z.object({ post_id: z.string() + }), + query: z.object({ + sort: z.enum(['newest-first', 'oldest-first']).default('oldest-first') }) }); const self = hasAuth() ? auth().self : null; @@ -162,7 +165,8 @@ postsRouter.get('/:post_id', async function (req, res) { } // increase limit for post replies since there's no pagination yet - const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort: 'oldest', limit: 500 }))?.data.items ?? []; + const sort: StandardSortEnum = query.sort === 'newest-first' ? 'newest' : 'oldest'; + const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort, limit: 500 }))?.data.items ?? []; const postPNID = await getUserAccountData(post.pid); const canPost = !!self && isPostingAllowed(community, self, post); @@ -172,7 +176,8 @@ postsRouter.get('/:post_id', async function (req, res) { postPNID, replies, canPost, - userContent: self?.content ?? null + userContent: self?.content ?? null, + sort: query.sort }; res.jsxForDirectory({ web: , diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx index e02ce2b1..e030fe17 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx @@ -111,6 +111,13 @@ export function WebCommunityView(props: CommunityViewProps): ReactNode { ) : null} + {user.perms.moderator && props.canPost + ? ( + + Create post + + ) + : null}
    ; + if (!user.perms.moderator) { + content = ( +
    +

    Posting is disabled on web

    +
    + ); + } + return (

    - + {content}
    ); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx index 19ecbee5..61ea020a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx @@ -4,6 +4,8 @@ import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalVie import { WebPostView } from '@/services/juxt-web/views/web/post'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; +import { WebUIIcon } from '@/services/juxt-web/views/web/components/ui/WebUIIcon'; import type { ReactNode } from 'react'; import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { Community, Post, SelfContent } from '@/api/generated'; @@ -15,6 +17,7 @@ export type PostPageViewProps = { community: Community; replies: Post[]; canPost: boolean; + sort: 'newest-first' | 'oldest-first'; }; function PostHead(props: PostPageViewProps): ReactNode { @@ -67,6 +70,8 @@ function PostHead(props: PostPageViewProps): ReactNode { } export function WebPostPageView(props: PostPageViewProps): ReactNode { + const user = useUser(); + return ( }>

    @@ -75,6 +80,28 @@ export function WebPostPageView(props: PostPageViewProps): ReactNode {
    +
    +

    Replies

    + {user.perms.moderator + ? ( +
    + + + + ) + : null } + {props.sort === 'newest-first' + ? ( + + + + ) + : ( + + + + )} +
    {props.replies.map(replyPost => (
    diff --git a/apps/juxtaposition-ui/webfiles/web/css/web.scss b/apps/juxtaposition-ui/webfiles/web/css/web.scss index cb061518..c2e6af2e 100644 --- a/apps/juxtaposition-ui/webfiles/web/css/web.scss +++ b/apps/juxtaposition-ui/webfiles/web/css/web.scss @@ -1872,6 +1872,44 @@ button.report { z-index: 100; } +.reply-control-bar { + background: var(--background-dark); + border-radius: 8px; + padding: 0.6rem 1.5rem; + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; + + .reply-control-title { + flex: 1; + font-size: 14px; + } + + .reply-control-item { + transition: background ease-in-out 100ms, transform ease-in-out 100ms; + padding: 0.5rem 1rem; + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 14px; + + &:hover { + background: var(--background-alt); + } + + &:active { + transform: scale(0.95); + } + } + + .reply-icon { + transform: scale(0.8); + color: var(--text-light); + } +} + @include t.media(">=tablet", " Date: Tue, 30 Jun 2026 22:29:13 +0200 Subject: [PATCH 054/103] chore: remove broken cancel button --- .../src/services/juxt-web/views/web/newPostView.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx index 1e37bf4c..b21aaf33 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx @@ -102,11 +102,6 @@ export function WebNewPostView(props: NewPostViewProps): ReactNode {
    -
    From 2c2c5110f0decabf0dc270b7fbd4726674f8a620 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 13:36:18 +0200 Subject: [PATCH 055/103] feat: add postgres ORM --- .docker/create-dbs.sql | 2 + .docker/docker-compose.yml | 25 +- .gitignore | 1 + README.md | 34 +- apps/miiverse-api/package.json | 20 +- apps/miiverse-api/prisma.config.ts | 55 + apps/miiverse-api/src/config.ts | 7 + apps/miiverse-api/src/database.ts | 23 + apps/miiverse-api/src/models/schema.prisma | 8 + apps/miiverse-api/tsconfig.json | 3 +- docs/development.md | 70 ++ docs/extra-topics.md | 23 + package-lock.json | 1191 +++++++++++++++++++- 13 files changed, 1382 insertions(+), 80 deletions(-) create mode 100644 .docker/create-dbs.sql create mode 100644 apps/miiverse-api/prisma.config.ts create mode 100644 apps/miiverse-api/src/models/schema.prisma create mode 100644 docs/development.md create mode 100644 docs/extra-topics.md diff --git a/.docker/create-dbs.sql b/.docker/create-dbs.sql new file mode 100644 index 00000000..21df7ea3 --- /dev/null +++ b/.docker/create-dbs.sql @@ -0,0 +1,2 @@ +CREATE DATABASE friends; +CREATE DATABASE miiverse; diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 9a1ab02d..d9812cac 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -8,6 +8,18 @@ services: - "6379:6379" volumes: - redis_data:/data + postgres: + image: postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: "postgres" + POSTGRES_USER: "postgres" + POSTGRES_DB: "postgres" + volumes: + - "postgres_data:/var/lib/postgresql" + - ./create-dbs:/docker-entrypoint-initdb.d/init.sql mongo: image: mongo:8.0 restart: unless-stopped @@ -125,19 +137,8 @@ services: PN_FRIENDS_ACCOUNT_GRPC_PORT: 8123 PN_FRIENDS_ACCOUNT_GRPC_API_KEY: "12345678123456781234567812345678" - postgres: - image: postgres - restart: unless-stopped - ports: - - "5432:5432" - environment: - POSTGRES_PASSWORD: "postgres" - POSTGRES_USER: "postgres" - POSTGRES_DB: "friends" - volumes: - - "pg_data:/var/lib/postgresql" volumes: - pg_data: + postgres_data: mongo_data: mongo_config: redis_data: diff --git a/.gitignore b/.gitignore index c9abc464..8d535146 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ apps/juxtaposition-ui/webfiles/ctr/css/sprites.css apps/juxtaposition-ui/webfiles/ctr/images/sprites.png apps/juxtaposition-ui/src/api/generated apps/miiverse-api/internal.openapi.json +apps/miiverse-api/src/prisma diff --git a/README.md b/README.md index a3117f8f..7f960de7 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,9 @@ This means we both want to bring all features originally found in Miiverse into - The web platforms on the 3DS and Wii U are old, thus we need to use old web methodologies like AJAX. - The XML API of the Miiverse platform cannot be modified or extended, it needs to stay exactly as the consoles expect it. -# Running locally for development - -Prerequisites: -- Clone the repository -- Have a functional running [account server](https://github.com/PretendoNetwork/account) and [friends server](https://github.com/PretendoNetwork/friends) -- Have NodeJS 20 or higher installed -- Optional: have docker installed (highly recommended) - -After the prerequisites you need to run the following inside `.docker`: -```sh -docker compose up -d -``` -If you are not using docker for development, please set up the services listed in `.docker/docker-compose.yml` manually. - -Next up, you need to run the two services in `/apps`: -```bash -cd apps/miiverse-api -npm i -npm run dev -``` - -And in another terminal: -```bash -cd apps/juxtaposition-ui -npm i -npm run dev -``` - -You have to also make an `.env` file to configure your environment. Inspire it from the schema in `src/config.ts` in each service. - -You can use `PN_JUXTAPOSITION_UI_USE_PRESETS=docker` and `PN_MIIVERSE_API_USE_PRESETS=docker` to automatically set up everything that's in the docker compose file. +# Development + +Follow [this guide](./docs/development.md) on how to get set up for local development. # Translation diff --git a/apps/miiverse-api/package.json b/apps/miiverse-api/package.json index cd84eb3e..04014df9 100644 --- a/apps/miiverse-api/package.json +++ b/apps/miiverse-api/package.json @@ -6,12 +6,15 @@ "type": "module", "exports": null, "scripts": { - "dev": "tsup --watch --onSuccess \"node --enable-source-maps dist/server.js\"", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "build": "tsup && tsc --noEmit", "start": "node --enable-source-maps dist/server.js", - "test": "ts-node test/test.ts" + "build": "prisma generate && tsup && tsc --noEmit", + "dev": "prisma generate && tsup --watch --onSuccess \"npm start", + "migration:create": "prisma migrate dev --create-only && prisma generate", + "migration:deploy": "prisma migrate deploy", + "migration:reset": "prisma migrate reset", + "db:seed": "tsup && node --enable-source-maps dist/seed.entry.mjs", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", @@ -19,6 +22,8 @@ "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", "@pretendonetwork/grpc": "^2.4.3", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@repo/grpc-client": "^0.0.0", "crc": "^4.3.2", "express": "^5.2.1", @@ -31,6 +36,7 @@ "nice-grpc": "^2.1.14", "node-snowflake": "0.0.1", "pako": "^2.1.0", + "pg": "^8.22.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -44,9 +50,11 @@ "@types/express": "^4.17.17", "@types/multer": "^2.1.0", "@types/pako": "^2.0.4", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "eslint": "^9.39.4", + "prisma": "^7.8.0", "tsup": "^8.5.1", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/apps/miiverse-api/prisma.config.ts b/apps/miiverse-api/prisma.config.ts new file mode 100644 index 00000000..10eb3d24 --- /dev/null +++ b/apps/miiverse-api/prisma.config.ts @@ -0,0 +1,55 @@ +/* + * This file needs to be indivually compilable and runnable, so this does not depend on any existing project files + */ + +import { createConfig, loaders, zodV4SchemaToTransformer } from '@neato/config'; +import { defineConfig } from 'prisma/config'; +import { z } from 'zod'; +import type { SchemaTransformer, SchemaTransformerContext } from '@neato/config'; + +const schema = z.object({ + db: z.object({ + url: z.string() + }) +}); + +const dockerPreset: z.input = { + db: { + url: 'postgresql://postgres:postgres@localhost:5432/miiverse' + } +}; + +function flatZodSchema(schema: T): SchemaTransformer> { + const transformer = zodV4SchemaToTransformer>(schema); + return { + extract: () => transformer.extract().map(v => ({ + ...v, + normalizedKey: v.normalizedKey.replaceAll('__', '_') + })), + validate: (ctx: SchemaTransformerContext) => transformer.validate(ctx) + }; +} + +const config = createConfig({ + envPrefix: 'PN_MIIVERSE_API_', + presetKey: 'usePresets', + presets: { + docker: dockerPreset + }, + loaders: [ + loaders.environment(), + loaders.file('.env'), + loaders.file('config.json') + ], + schema: flatZodSchema(schema) +}); + +export default defineConfig({ + schema: 'src/models/schema.prisma', + migrations: { + path: 'src/models/migrations' + }, + datasource: { + url: config.db.url + } +}); diff --git a/apps/miiverse-api/src/config.ts b/apps/miiverse-api/src/config.ts index b202d5ec..12c7796d 100644 --- a/apps/miiverse-api/src/config.ts +++ b/apps/miiverse-api/src/config.ts @@ -30,6 +30,10 @@ const schema = z.object({ mongoose: z.object({ uri: z.string() }), + db: z.object({ + /** Postgres connection string */ + url: z.string() + }), s3: z.object({ endpoint: z.string(), key: z.string(), @@ -69,6 +73,9 @@ export const presets: Record = { mongoose: { uri: 'mongodb://localhost:27017/miiverse?directConnection=true' }, + db: { + url: 'postgres://postgres:postgres@localhost:5432/miiverse' + }, s3: { endpoint: 'http://localhost:9000', key: 'minioadmin', diff --git a/apps/miiverse-api/src/database.ts b/apps/miiverse-api/src/database.ts index a92c5282..78edf4c0 100644 --- a/apps/miiverse-api/src/database.ts +++ b/apps/miiverse-api/src/database.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import { PrismaPg } from '@prisma/adapter-pg'; import { logger } from '@/logger'; import { Community } from '@/models/community'; import { Content } from '@/models/content'; @@ -7,6 +8,7 @@ import { Endpoint } from '@/models/endpoint'; import { Post } from '@/models/post'; import { Settings } from '@/models/settings'; import { config } from '@/config'; +import { PrismaClient } from '@/prisma/client'; import type { HydratedEndpointDocument } from '@/models/endpoint'; import type { HydratedConversationDocument } from '@/models/conversation'; import type { HydratedContentDocument } from '@/types/mongoose/content'; @@ -14,6 +16,27 @@ import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +let prisma: PrismaClient | null = null; + +export function getDb() { + if (!prisma) { + throw new Error('Prisma used before initialisation'); + } + return prisma; +} + +export async function setupPrisma() { + const adapter = new PrismaPg({ connectionString: config.db.url }); + prisma = new PrismaClient({ adapter }); + + try { + await prisma.$connect(); + logger?.info('Connected to database!'); + } catch (err) { + logger?.error(err, 'Failed to connect to database, continueing anyway!'); + } +} + let connection: mongoose.Connection; mongoose.set('strictQuery', true); diff --git a/apps/miiverse-api/src/models/schema.prisma b/apps/miiverse-api/src/models/schema.prisma new file mode 100644 index 00000000..9a7c4f6e --- /dev/null +++ b/apps/miiverse-api/src/models/schema.prisma @@ -0,0 +1,8 @@ +generator client { + provider = "prisma-client" + output = "../prisma" +} + +datasource db { + provider = "postgresql" +} diff --git a/apps/miiverse-api/tsconfig.json b/apps/miiverse-api/tsconfig.json index 991abd15..6b03984e 100644 --- a/apps/miiverse-api/tsconfig.json +++ b/apps/miiverse-api/tsconfig.json @@ -22,6 +22,7 @@ } }, "include": [ - "src" + "src", + "prisma.config.ts" ] } \ No newline at end of file diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..038675b6 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,70 @@ +# Development setup + +To get set up with a local instance of Juxtaposition, you need the following things: +- [Docker Desktop](https://docs.docker.com/get-started/get-docker/) (Or docker engine if you're used to that) +- [NodeJS 20+](https://nodejs.org/en/download) + +After you have those installed, we will need to set up the following services: +- **Dependent services** - These are all tools that the network needs, like databases and proxies. They run in docker +- **Juxtaposition UI** - The service that serves all the frontend for all platforms +- **Miiverse API** - This is the backend of Juxtaposition UI and miiverse functionality, it's required for the UI to run + +## 1. Setting up `Dependent services` + +1. Run `docker compose up -d` whiel being in the `/.docker` folder. +2. Follow the initialization steps listed in [`/.docker/README.md`](../.docker/README.md). + +That's all, now move on the next step! + +## 2. Running `miiverse-api` + +1. Go into the `/apps/miiverse-api` folder, create the file `.env` with the following contents: + ```sh + PN_MIIVERSE_API_USE_PRESETS=docker + ``` +2. Install required dependencies with `npm i` +3. Initialize the database with `npm run migration:deploy` +4. Run the service with `npm run dev` + +You have to keep this server running while testing and using Juxtaposition. + +**Tip:** Read [`Extra topics / Databases and migrations`](./extra-topics.md) to learn how to work with the databases of Juxtaposition. + +## 3. Running `juxtposition-ui` + +1. Go into the `/apps/juxtaposition-ui` folder, create the file `.env` with the following contents: + ```sh + PN_JUXTAPOSITION_UI_USE_PRESETS=docker + ``` +2. Install required dependencies with `npm i` +3. Run the service with `npm run dev` + +You have also have to keep this server running while testing and using Juxtaposition. + +### 4. Connecting with browser + +If you want to connect to Juxtaposition through your browser, follow these steps: + +1. Install Firefox +2. In firefox, create a new profile for Juxtaposition by going to `profiles > new profile`. Choose a cool name like `juxt`. +3. Go into settings, search for `connection and software security`. Click `Advanced settings` and then `Configure proxy`, configure the following: + - Select `Manual proxy configuration` + - HTTP proxy: `localhost` + - HTTP proxy port: `888` + - Select `Also use this proxy for HTTPS` + - Keep the rest as default & press OK +4. Go to `Extensions and themes`, select the settings icon and choose `Install extension from file` + - Choose the file at `.docker/juxt-cookie-sync/juxt-cookie-sync-1.0.xpi` + - This will log you in to all Juxtaposition platforms when using your firefox profile. + +Done! Now when you use this firefox profile, it will be connected to your local Juxtaposition instance. + +### 5. Done! + +You now have a working local setup of Juxtaposition! + +Here are a couple useful links: +- [Proxy dashboard](http://localhost:8081?token=letmein) (password is `letmein`) +- [Local Juxtaposition web](https://juxt.pretendo.network/) (Use the firefox profile) +- [Local Juxtaposition portal (WiiU)](https://portal.olv.pretendo.cc/) (Use the firefox profile) +- [Local Juxtaposition ctr (3ds)](https://ctr.olv.pretendo.cc/) (Use the firefox profile) diff --git a/docs/extra-topics.md b/docs/extra-topics.md new file mode 100644 index 00000000..9a899ad1 --- /dev/null +++ b/docs/extra-topics.md @@ -0,0 +1,23 @@ +# Extra topics + +This file contains some extra topics that are helpful for development. + +## 1. Databases and migrations + +Juxtaposition currently uses two databases: postgresql (with prisma) and mongodb (with mongoose). +Mongodb is being phased out and replaced with postgresql. + +Here are some situations which require extra steps to work with the databases, run all steps. + +Before starting the app: +- `mongodb`: No steps required +- `postgresql`: Deploy the new migrations using `npm run migration:deploy` + +Changing database models: +- `mongodb`: Automatic, no manual steps needed +- `postgresql`: Create a migration using `npm run migration:create` and deploy it using `npm run migration:deploy` + +Creating a data migration: +- `mongodb`: Add a mongosh script to `/migrations` +- `postgresql`: Create a blank migration using `npm run migration:create` and fill it with your SQL script to do a data migration +- Cross-db data migrations: Create a NodeJS script in `/migrations` and give it a package.json for it's dependencies. diff --git a/package-lock.json b/package-lock.json index 9161cb38..4b6437a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -396,6 +396,8 @@ "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", "@pretendonetwork/grpc": "^2.4.3", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@repo/grpc-client": "^0.0.0", "crc": "^4.3.2", "express": "^5.2.1", @@ -408,6 +410,7 @@ "nice-grpc": "^2.1.14", "node-snowflake": "0.0.1", "pako": "^2.1.0", + "pg": "^8.22.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -421,8 +424,10 @@ "@types/express": "^4.17.17", "@types/multer": "^2.1.0", "@types/pako": "^2.0.4", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "eslint": "^9.39.4", + "prisma": "^7.8.0", "tsup": "^8.5.1", "typescript": "^5.9.3" } @@ -1724,6 +1729,37 @@ "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -2577,6 +2613,19 @@ "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2717,6 +2766,13 @@ "wasm-feature-detect": "^1.2.11" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", @@ -3191,6 +3247,361 @@ "integrity": "sha512-l6dfw5MHe9k3Q4/6vanuHUHJK82DjwxGrv4dfdRc47WEcgLyGCJx8ccmgL/H3MXwmcpmJNwhEbMQ5l8xJFHccQ==", "dev": true }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/config/node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/@prisma/config/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/config/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/config/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@prisma/config/node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/@prisma/config/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/@prisma/config/node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/@prisma/config/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3255,6 +3666,153 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@redis/bloom": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", @@ -3272,6 +3830,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4439,6 +4998,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -4544,6 +5104,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -4558,7 +5129,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4697,6 +5268,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5187,6 +5759,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5507,6 +6080,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5523,6 +6106,13 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/bin-pack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bin-pack/-/bin-pack-1.0.2.tgz", @@ -5644,6 +6234,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5939,6 +6530,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6064,8 +6668,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -6255,7 +6858,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cwise-compiler": { @@ -6355,6 +6958,16 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -6443,6 +7056,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6552,6 +7175,17 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -6564,6 +7198,16 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -6580,6 +7224,19 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6769,6 +7426,7 @@ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6902,6 +7560,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7070,6 +7729,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7440,6 +8100,7 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "~0.7.2", "cookie-signature": "~1.0.7", @@ -7495,6 +8156,29 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -7505,7 +8189,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "devOptional": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -7540,6 +8224,23 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", @@ -7723,6 +8424,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -7822,6 +8540,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7870,6 +8598,13 @@ "through": "^2.3.4" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8034,6 +8769,20 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -8150,6 +8899,17 @@ "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -8195,6 +8955,13 @@ "node": ">= 0.8" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/i18next": { "version": "25.8.18", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", @@ -8214,6 +8981,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6" }, @@ -8650,6 +9418,13 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9086,6 +9861,22 @@ "loose-envify": "cli.js" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -9430,6 +10221,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9441,6 +10270,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -10242,6 +11084,96 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10523,6 +11455,59 @@ } } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -10544,6 +11529,41 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", @@ -10570,6 +11590,7 @@ "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" @@ -10589,6 +11610,25 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -10649,6 +11689,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -10735,6 +11792,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10744,6 +11802,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10864,6 +11923,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.11.0", "@redis/client": "5.11.0", @@ -10917,6 +11977,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, "node_modules/replace-ext": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz", @@ -10933,6 +12003,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -10972,6 +12052,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11087,7 +12177,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11213,7 +12302,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.99.0.tgz", "integrity": "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -11262,7 +12350,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.99.0" } @@ -11279,7 +12366,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11296,7 +12382,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11313,7 +12398,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11330,7 +12414,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11347,7 +12430,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11364,7 +12446,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11381,7 +12462,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11398,7 +12478,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11415,7 +12494,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11432,7 +12510,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11449,7 +12526,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11466,7 +12542,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11483,7 +12558,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11500,7 +12574,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11517,7 +12590,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.99.0" } @@ -11534,7 +12606,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11551,7 +12622,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11561,7 +12631,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11696,6 +12765,12 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -11868,6 +12943,19 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -12033,6 +13121,16 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -12051,6 +13149,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -12394,7 +13499,6 @@ "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -12407,7 +13511,6 @@ "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -12881,6 +13984,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13003,6 +14107,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -13101,6 +14206,21 @@ "node": ">= 0.4.0" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -13116,8 +14236,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vary": { "version": "1.1.2", @@ -13421,11 +14540,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From decedf0aaf7c8f61593aebbedec538812d0cb6fe Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 14:50:50 +0200 Subject: [PATCH 056/103] chore: add prisma to context --- .vscode/settings.json | 6 ++++-- apps/miiverse-api/eslint.config.mjs | 3 +++ apps/miiverse-api/src/services/internal/builder/context.ts | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f32902cf..3e0053f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ ], "eslint.format.enable": true, "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "js/ts.preferences.importModuleSpecifier": "non-relative", "[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, @@ -18,12 +19,13 @@ "[html]": { "editor.defaultFormatter": "vscode.html-language-features" }, - "javascript.preferences.importModuleSpecifier": "non-relative", - "typescript.preferences.importModuleSpecifier": "non-relative", "[typescriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "[scss]": { "editor.defaultFormatter": "vscode.css-language-features" }, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + } } \ No newline at end of file diff --git a/apps/miiverse-api/eslint.config.mjs b/apps/miiverse-api/eslint.config.mjs index 14a0ab00..9e704ea7 100644 --- a/apps/miiverse-api/eslint.config.mjs +++ b/apps/miiverse-api/eslint.config.mjs @@ -8,5 +8,8 @@ export default defineConfig([ // Zod has too complex types to make a return type '@typescript-eslint/explicit-function-return-type': 'off' } + }, + { + ignores: ['src/prisma'] } ]); diff --git a/apps/miiverse-api/src/services/internal/builder/context.ts b/apps/miiverse-api/src/services/internal/builder/context.ts index 9b9e1c8f..7ae35f8e 100644 --- a/apps/miiverse-api/src/services/internal/builder/context.ts +++ b/apps/miiverse-api/src/services/internal/builder/context.ts @@ -1,5 +1,7 @@ +import { getDb } from '@/database'; import type { Request, Response } from 'express'; import type { z } from 'zod'; +import type { PrismaClient } from '@/prisma/client'; export type ZodRouteSchemaSchape = { body?: z.ZodType; @@ -12,6 +14,7 @@ export type ZodRouteContext; query: z.infer; params: z.infer; + db: PrismaClient; auth: TAuthCtx; }; @@ -24,6 +27,7 @@ export function createRouteContext; } From a26e7063244ee5f2085835fe30aced216a176c2b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 14:51:08 +0200 Subject: [PATCH 057/103] feat: port notification model to postgres in api --- .../src/models/notifications.js | 23 --- apps/miiverse-api/src/models/notification.ts | 19 -- apps/miiverse-api/src/models/schema.prisma | 31 ++++ .../internal/contract/notification.ts | 37 ++-- .../internal/routes/admin/adminReports.ts | 4 +- .../internal/routes/admin/adminUsers.ts | 4 +- .../services/internal/routes/notification.ts | 50 ++--- .../src/services/internal/routes/self.ts | 10 +- .../services/internal/routes/userProfile.ts | 4 +- .../services/internal/utils/notifications.ts | 175 +++++++++++++----- 10 files changed, 209 insertions(+), 148 deletions(-) delete mode 100644 apps/juxtaposition-ui/src/models/notifications.js delete mode 100644 apps/miiverse-api/src/models/notification.ts diff --git a/apps/juxtaposition-ui/src/models/notifications.js b/apps/juxtaposition-ui/src/models/notifications.js deleted file mode 100644 index 792d2ae9..00000000 --- a/apps/juxtaposition-ui/src/models/notifications.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Schema, model } from 'mongoose'; - -export const NotificationSchema = new Schema({ - pid: String, - type: String, - link: String, - image: String, - text: String, - objectID: String, - users: [{ - user: String, - timestamp: Date - }], - read: Boolean, - lastUpdated: Date -}); - -NotificationSchema.methods.markRead = async function () { - this.set('read', true); - await this.save(); -}; - -export const NOTIFICATION = model('NOTIFICATION', NotificationSchema); diff --git a/apps/miiverse-api/src/models/notification.ts b/apps/miiverse-api/src/models/notification.ts deleted file mode 100644 index e869ceb6..00000000 --- a/apps/miiverse-api/src/models/notification.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { INotification, NotificationModel } from '@/types/mongoose/notification'; - -const NotificationSchema = new Schema({ - pid: String, - type: String, - link: String, - image: String, - text: String, - objectID: String, - users: [{ - user: String, - timestamp: Date - }], - read: Boolean, - lastUpdated: Date -}); - -export const Notification = model('Notification', NotificationSchema); diff --git a/apps/miiverse-api/src/models/schema.prisma b/apps/miiverse-api/src/models/schema.prisma index 9a7c4f6e..b499c981 100644 --- a/apps/miiverse-api/src/models/schema.prisma +++ b/apps/miiverse-api/src/models/schema.prisma @@ -6,3 +6,34 @@ generator client { datasource db { provider = "postgresql" } + +enum NotificationType { + Follow + System +} + +/// Notification content +model Notification { + id String @id + type NotificationType + content Json /// Any extra context, dependent on type. Like users who followed + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + notificationRecipients NotificationRecipient[] + + @@map("notifications") +} + +/// Received notification +model NotificationRecipient { + id String @id + pid Int + hasRead Boolean @default(false) @map("has_read") + + notificationId String @map("notification_id") + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@index([pid]) + @@map("notification_recipients") +} diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 1a1d23b7..89dd882f 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -1,40 +1,25 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; -import type { INotification } from '@/types/mongoose/notification'; +import type { Notification, NotificationRecipient } from '@/prisma/client'; -export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['follow', 'notice'])); +export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['system', 'follow'])); export type NotificationType = z.infer; export const notificationSchema = z.object({ - toPid: z.number(), - resourceId: z.string(), - link: z.string().nullable(), - imageUrl: z.string(), - content: z.string(), - read: z.boolean(), + pid: z.number(), + hasRead: z.boolean(), updatedAt: z.date(), - type: notificationTypeSchema, - users: z.array(z.object({ - pid: z.number(), - timestamp: z.date() - })) + type: notificationTypeSchema + // TODO implement new DTO for notification content }).openapi('Notification'); export type NotificationDto = z.infer; -export function mapNotification(notif: INotification): NotificationDto { +export function mapNotification(recipient: NotificationRecipient, notif: Notification): NotificationDto { return { - toPid: Number(notif.pid), - resourceId: notif.objectID, - read: notif.read, - updatedAt: notif.lastUpdated, - link: notif.link, - type: notif.type as any, - users: notif.users.map(v => ({ - pid: Number(v.user), - timestamp: v.timestamp - })), - content: notif.text, - imageUrl: notif.image + pid: recipient.pid, + hasRead: recipient.hasRead, + updatedAt: notif.updatedAt, + type: notif.type === 'Follow' ? 'follow' : 'system' }; } diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts index 480466a2..7b983731 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts @@ -73,7 +73,7 @@ adminReportsRouter.post({ }), response: resultSchema }, - async handler({ params, body, auth }) { + async handler({ db, params, body, auth }) { const account = auth!; const report = await Report.findOne({ _id: params.id }); @@ -105,7 +105,7 @@ adminReportsRouter.post({ note: reason } }); - await createNewPostDeletionNotification({ + await createNewPostDeletionNotification(db, { postAuthor: post.pid, post: post, reason diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts index 3bd468d1..0cd140f4 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts @@ -116,7 +116,7 @@ adminUsersRouter.patch({ }), response: moderationProfileSchema }, - async handler({ params, body, auth }) { + async handler({ params, db, body, auth }) { const account = auth!; const oldSettings = await Settings.findOne({ pid: params.id }); if (!oldSettings) { @@ -141,7 +141,7 @@ adminUsersRouter.patch({ const accountStatusChanged = oldSettings.account_status !== settings.account_status; if (accountStatusChanged && settings.account_status === 1) { - await createNewLimitedPostingNotification({ + await createNewLimitedPostingNotification(db, { pid: settings.pid, banLiftDate: settings.ban_lift_date ?? null, reason: settings.ban_reason ?? null diff --git a/apps/miiverse-api/src/services/internal/routes/notification.ts b/apps/miiverse-api/src/services/internal/routes/notification.ts index afb0b300..8a347e9f 100644 --- a/apps/miiverse-api/src/services/internal/routes/notification.ts +++ b/apps/miiverse-api/src/services/internal/routes/notification.ts @@ -2,11 +2,8 @@ import { z } from 'zod'; import { guards } from '@/services/internal/middleware/guards'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; -import { deleteOptional } from '@/services/internal/utils'; -import { Notification } from '@/models/notification'; import { mapNotification, notificationSchema } from '@/services/internal/contract/notification'; -import type { RootFilterQuery } from 'mongoose'; -import type { INotification } from '@/types/mongoose/notification'; +import type { NotificationRecipientWhereInput } from '@/prisma/models'; export const notificationsRouter = createInternalApiRouter(); @@ -21,32 +18,43 @@ notificationsRouter.get({ }).extend(pageControlSchema()), response: pageDtoSchema(notificationSchema) }, - async handler({ query, auth }) { + async handler({ query, db, auth }) { const account = auth!; - const dbQuery: RootFilterQuery = deleteOptional({ - read: query.read, + const dbQuery: NotificationRecipientWhereInput = { + hasRead: query.read, pid: account.pnid.pid + }; + const notifications = await db.notificationRecipient.findMany({ + where: dbQuery, + take: query.limit, + skip: query.offset, + orderBy: { + notification: { + updatedAt: 'desc' + } + }, + include: { + notification: true + } + }); + const total = await db.notificationRecipient.count({ + where: dbQuery }); - const notifications = await Notification - .find(dbQuery) - .sort({ lastUpdated: -1 }) - .limit(query.limit) - .skip(query.offset); - const total = await Notification.countDocuments(dbQuery); if (query.markAsRead) { - await Notification.updateMany({ - _id: { - $in: notifications.map(v => v._id) - } - }, { - $set: { - read: true + await db.notificationRecipient.updateMany({ + where: { + id: { + in: notifications.map(v => v.id) + } + }, + data: { + hasRead: true } }); } - return mapPage(total, notifications.map(v => mapNotification(v))); + return mapPage(total, notifications.map(v => mapNotification(v, v.notification))); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts index 55af71ee..fa3a341e 100644 --- a/apps/miiverse-api/src/services/internal/routes/self.ts +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -3,7 +3,7 @@ import { createInternalApiRouter } from '@/services/internal/builder/router'; import { errors } from '@/services/internal/errors'; import { mapBannedSelf, mapSelf, mapSelfNotificationCount, selfNotificationCountSchema, selfSchema } from '@/services/internal/contract/self'; import { Settings } from '@/models/settings'; -import { Notification } from '@/models/notification'; +import { getDb } from '@/database'; export const selfRouter = createInternalApiRouter(); @@ -76,9 +76,11 @@ selfRouter.get({ async handler({ auth }) { const account = auth!; - const notificationCount = await Notification.countDocuments({ - pid: account.pnid.pid, - read: false + const notificationCount = await getDb().notificationRecipient.count({ + where: { + pid: account.pnid.pid, + hasRead: false + } }); return mapSelfNotificationCount(notificationCount); diff --git a/apps/miiverse-api/src/services/internal/routes/userProfile.ts b/apps/miiverse-api/src/services/internal/routes/userProfile.ts index 8d632257..2a56dd70 100644 --- a/apps/miiverse-api/src/services/internal/routes/userProfile.ts +++ b/apps/miiverse-api/src/services/internal/routes/userProfile.ts @@ -238,7 +238,7 @@ userProfileRouter.post({ }), response: followSchema }, - async handler({ params, auth }) { + async handler({ params, db, auth }) { const targetUserPid = params.id; const targetUser = await Settings.findOne({ pid: targetUserPid }); const targetUserContent = await Content.findOne({ pid: targetUserPid }); @@ -265,7 +265,7 @@ userProfileRouter.post({ await targetUserContent.save(); await currentUser.content.save(); - await createNewFollowNotification({ currentUser: currentUserPid, userToFollow: targetUserPid }); + await createNewFollowNotification(db, { currentUser: currentUserPid, userToFollow: targetUserPid }); return mapFollowUser('follow', targetUserContent); } }); diff --git a/apps/miiverse-api/src/services/internal/utils/notifications.ts b/apps/miiverse-api/src/services/internal/utils/notifications.ts index ef6c4da0..7fd0c320 100644 --- a/apps/miiverse-api/src/services/internal/utils/notifications.ts +++ b/apps/miiverse-api/src/services/internal/utils/notifications.ts @@ -1,5 +1,5 @@ -import { Notification } from '@/models/notification'; import { humanDate } from '@/services/internal/utils/dates'; +import type { PrismaClient } from '@/prisma/client'; import type { IPost } from '@/types/mongoose/post'; export type FollowNotificationOptions = { @@ -19,78 +19,155 @@ export type LimitedPostingNotificationOptions = { reason: string | null; }; -export async function createNewFollowNotification(ops: FollowNotificationOptions): Promise { +export type FollowNotificationContent = { + users: { + timestamp: string; // Iso timestamp + pid: number; + }[]; +}; + +export type SystemNotificationContent = { + imagePath: string; + link: string; + text: string; +}; + +export async function createNewFollowNotification(db: PrismaClient, ops: FollowNotificationOptions): Promise { const now = new Date(); - const url = `/users/${ops.currentUser}`; - // Same user has followed previously - const existingNotif = await Notification.findOne({ pid: ops.userToFollow, objectID: ops.currentUser }); - if (existingNotif) { - existingNotif.lastUpdated = now; - existingNotif.read = false; - await existingNotif.save(); + // Prevent sending a new notification if the same user has done so recently + const weekInMs = 7 * 24 * 60 * 60 * 1000; + const recentFollowNotifs = await db.notificationRecipient.findMany({ + where: { + pid: ops.userToFollow, + notification: { + type: 'Follow', + updatedAt: { + gte: new Date(now.getTime() - weekInMs) // Get 7 days worth of follow notifications + } + } + }, + include: { + notification: true + } + }); + const followNotifsContent = recentFollowNotifs.map(v => v.notification.content as FollowNotificationContent); + const hasFollowNotifContentForUser = followNotifsContent.some(content => content.users.some(usr => usr.pid === ops.currentUser)); + if (hasFollowNotifContentForUser) { + // Don't send any notification to prevent follow notif spam return; } - // Combine existing follower notification - const last60min = new Date(now.getTime() - 60 * 60 * 1000); - const groupedNotif = await Notification.findOne({ pid: ops.userToFollow, type: 'follow', lastUpdated: { $gte: last60min } }); - if (groupedNotif) { - groupedNotif.users.push({ - user: ops.currentUser, - timestamp: now + // Group follower notifications into batch + const hourInMs = 60 * 60 * 1000; + const recentFollowNotif = await db.notificationRecipient.findFirst({ + where: { + pid: ops.userToFollow, + notification: { + type: 'Follow', + updatedAt: { + gte: new Date(now.getTime() - hourInMs) + } + } + }, + include: { + notification: true + } + }); + if (recentFollowNotif) { + const newContent = recentFollowNotif.notification.content as FollowNotificationContent; + newContent.users.push({ + pid: ops.currentUser, + timestamp: now.toISOString() + }); + await db.notification.update({ + where: { + id: recentFollowNotif.notificationId + }, + data: { + content: newContent, + updatedAt: now + } + }); + await db.notificationRecipient.update({ + where: { + id: recentFollowNotif.id + }, + data: { + hasRead: false + } }); - groupedNotif.lastUpdated = now; - groupedNotif.link = url; - groupedNotif.objectID = ops.currentUser.toString(); - groupedNotif.read = false; - await groupedNotif.save(); return; } // Create new notification - await Notification.create({ - pid: ops.userToFollow, - type: 'follow', + const content: FollowNotificationContent = { users: [{ - user: ops.currentUser, - timestamp: now - }], - link: url, - objectID: ops.currentUser.toString(), - read: false, - lastUpdated: now + pid: ops.currentUser, + timestamp: now.toISOString() + }] + }; + await db.notification.create({ + data: { + id: 'test', // TODO create uuid + content, + type: 'Follow', + notificationRecipients: { + create: { + id: 'test', // TODO create uuid + pid: ops.userToFollow + } + } + } }); } -export async function createNewPostDeletionNotification(ops: PostDeletionNotificationOptions): Promise { +export async function createNewPostDeletionNotification(db: PrismaClient, ops: PostDeletionNotificationOptions): Promise { const postType = ops.post.parent ? 'comment' : 'post'; - - await Notification.create({ - pid: ops.postAuthor, - type: 'notice', - read: false, - lastUpdated: new Date(), + const content: SystemNotificationContent = { + imagePath: '/images/bandwidthalert.png', + link: '/titles/2551084080/new', text: `Your ${postType} "${ops.post.id}" has been removed` + (ops.reason ? ` for the following reason: "${ops.reason}". ` : '. ') + `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/).`, - image: '/images/bandwidthalert.png', - link: '/titles/2551084080/new' + `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/).` + }; + await db.notification.create({ + data: { + id: 'test', // TODO create uuid + content, + type: 'System', + notificationRecipients: { + create: { + id: 'test', // TODO create uuid + pid: ops.postAuthor + } + } + } }); } -export async function createNewLimitedPostingNotification(ops: LimitedPostingNotificationOptions): Promise { +export async function createNewLimitedPostingNotification(db: PrismaClient, ops: LimitedPostingNotificationOptions): Promise { const firstSentence = ops.banLiftDate ? `You have been Limited from Posting until ${humanDate(ops.banLiftDate)}. ` : `You have been Limited from Posting. `; - - await Notification.create({ - pid: ops.pid, - type: 'notice', + const content: SystemNotificationContent = { + imagePath: '/images/bandwidthalert.png', + link: '/titles/2551084080/new', text: firstSentence + (ops.reason ? `Reason: "${ops.reason}". ` : '') + `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/).`, - image: '/images/bandwidthalert.png', - link: '/titles/2551084080/new' + `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/).` + }; + await db.notification.create({ + data: { + id: 'test', // TODO create uuid + content, + type: 'System', + notificationRecipients: { + create: { + id: 'test', // TODO create uuid + pid: ops.pid + } + } + } }); } From 03e04e803bec3ef5b87ce5a634f0e2229368f209 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 15:22:53 +0200 Subject: [PATCH 058/103] feat: implement new notification DTO --- .../internal/contract/notification.ts | 55 ++++++++++++++++++- .../services/internal/utils/notifications.ts | 13 +++-- apps/miiverse-api/src/util.ts | 6 +- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 89dd882f..415320bd 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -1,25 +1,74 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; import type { Notification, NotificationRecipient } from '@/prisma/client'; +import type { FollowNotificationContent, SystemNotificationContent } from '@/services/internal/utils/notifications'; export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['system', 'follow'])); export type NotificationType = z.infer; +export const followNotificationSchema = asOpenapi('FollowNotification', z.object({ + type: z.literal('follow'), + content: z.object({ + users: z.array(z.object({ + pid: z.number(), + timestamp: z.date() + })) + }) +})); + +export const systemNotificationSchema = asOpenapi('SystemNotification', z.object({ + type: z.literal('system'), + content: z.object({ + imagePath: z.string(), + link: z.string(), + text: z.string() + }) +})); + export const notificationSchema = z.object({ pid: z.number(), hasRead: z.boolean(), updatedAt: z.date(), - type: notificationTypeSchema - // TODO implement new DTO for notification content + notif: z.discriminatedUnion('type', [ + followNotificationSchema, + systemNotificationSchema + ]) }).openapi('Notification'); export type NotificationDto = z.infer; export function mapNotification(recipient: NotificationRecipient, notif: Notification): NotificationDto { + let data: NotificationDto['notif'] | null = null; + + if (notif.type === 'Follow') { + const content = notif.content as FollowNotificationContent; + data = { + type: 'follow', + content: { + users: content.users.map(v => ({ + pid: v.pid, + timestamp: new Date(v.timestamp) + })) + } + }; + } + + if (notif.type === 'System') { + const content = notif.content as SystemNotificationContent; + data = { + type: 'system', + content + }; + } + + if (!data) { + throw new Error(`No DTO mapping for notification ${notif.type} found`); + } + return { pid: recipient.pid, hasRead: recipient.hasRead, updatedAt: notif.updatedAt, - type: notif.type === 'Follow' ? 'follow' : 'system' + notif: data }; } diff --git a/apps/miiverse-api/src/services/internal/utils/notifications.ts b/apps/miiverse-api/src/services/internal/utils/notifications.ts index 7fd0c320..84543946 100644 --- a/apps/miiverse-api/src/services/internal/utils/notifications.ts +++ b/apps/miiverse-api/src/services/internal/utils/notifications.ts @@ -1,4 +1,5 @@ import { humanDate } from '@/services/internal/utils/dates'; +import { genId } from '@/util'; import type { PrismaClient } from '@/prisma/client'; import type { IPost } from '@/types/mongoose/post'; @@ -109,12 +110,12 @@ export async function createNewFollowNotification(db: PrismaClient, ops: FollowN }; await db.notification.create({ data: { - id: 'test', // TODO create uuid + id: genId(), content, type: 'Follow', notificationRecipients: { create: { - id: 'test', // TODO create uuid + id: genId(), pid: ops.userToFollow } } @@ -134,12 +135,12 @@ export async function createNewPostDeletionNotification(db: PrismaClient, ops: P }; await db.notification.create({ data: { - id: 'test', // TODO create uuid + id: genId(), content, type: 'System', notificationRecipients: { create: { - id: 'test', // TODO create uuid + id: genId(), pid: ops.postAuthor } } @@ -159,12 +160,12 @@ export async function createNewLimitedPostingNotification(db: PrismaClient, ops: }; await db.notification.create({ data: { - id: 'test', // TODO create uuid + id: genId(), content, type: 'System', notificationRecipients: { create: { - id: 'test', // TODO create uuid + id: genId(), pid: ops.pid } } diff --git a/apps/miiverse-api/src/util.ts b/apps/miiverse-api/src/util.ts index 2f7432c9..424c975f 100644 --- a/apps/miiverse-api/src/util.ts +++ b/apps/miiverse-api/src/util.ts @@ -1,4 +1,4 @@ -import crypto from 'node:crypto'; +import crypto, { randomUUID } from 'node:crypto'; import { DeleteObjectsCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import crc32 from 'crc/crc32'; import { config } from '@/config'; @@ -300,3 +300,7 @@ export async function performAutomodAction(post: IPostInput, evaluation: Automod throw new Error('Invalid automod evaluation'); } + +export function genId(): string { + return randomUUID(); +} From e415608db0556b81ea628bb8c5d442c01bbe6fe9 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 15:25:20 +0200 Subject: [PATCH 059/103] chore: fix db init bugs --- apps/miiverse-api/package.json | 2 +- apps/miiverse-api/src/server.entry.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/miiverse-api/package.json b/apps/miiverse-api/package.json index 04014df9..c62b806e 100644 --- a/apps/miiverse-api/package.json +++ b/apps/miiverse-api/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node --enable-source-maps dist/server.js", "build": "prisma generate && tsup && tsc --noEmit", - "dev": "prisma generate && tsup --watch --onSuccess \"npm start", + "dev": "prisma generate && tsup --watch --onSuccess \"npm start\"", "migration:create": "prisma migrate dev --create-only && prisma generate", "migration:deploy": "prisma migrate deploy", "migration:reset": "prisma migrate reset", diff --git a/apps/miiverse-api/src/server.entry.ts b/apps/miiverse-api/src/server.entry.ts index a74096c2..560b7836 100644 --- a/apps/miiverse-api/src/server.entry.ts +++ b/apps/miiverse-api/src/server.entry.ts @@ -1,7 +1,7 @@ import '@/extend-zod'; // Needs to be the first import import express from 'express'; import expressMetrics from 'express-prom-bundle'; -import { connect as connectDatabase } from '@/database'; +import { connect as connectDatabase, setupPrisma } from '@/database'; import { logger } from '@/logger'; import { loggerHttp } from '@/loggerHttp'; import auth from '@/middleware/auth'; @@ -84,6 +84,7 @@ async function main(): Promise { setupS3(); connectGrpc(); await connectDatabase(); + await setupPrisma(); await initImageProcessing(); app.listen(port, '0.0.0.0', () => { From ed25a4f7a711f750d21711258c6ee725cc868cbf Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 15:42:57 +0200 Subject: [PATCH 060/103] feat: make juxtaposition-ui use the new notification dto --- .../views/ctr/notificationListView.tsx | 36 ++++++++-------- .../views/portal/notificationListView.tsx | 36 ++++++++-------- .../views/web/notificationListView.tsx | 42 ++++++++++--------- 3 files changed, 60 insertions(+), 54 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index 53315aec..c06d7b0c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -11,42 +11,44 @@ import type { NotificationItemProps, NotificationListViewProps, NotificationWrap function CtrNotificationItem(props: NotificationItemProps): ReactNode { const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { + const data = props.notification; + if (data.notif.type === 'follow') { const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { + if (users.length === 2) { i18nKey = 'notifications.new_follower/two'; } - if (notif.users.length === 3) { + if (users.length === 3) { i18nKey = 'notifications.new_follower/three'; } - if (notif.users.length > 3) { + if (users.length > 3) { i18nKey = 'notifications.new_follower/multiple'; } return ( <> - +

    - + , - follower_two: + follower_one: , + follower_two: }} /> {' '} - {humanFromNow(notif.updatedAt)} + {humanFromNow(data.updatedAt)}

    @@ -54,17 +56,17 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { ); } - if (notif.type === 'notice') { + if (data.notif.type === 'system') { return ( <> - +
    - +

    - {notif.content} + {data.notif.content.text} {' '} - {humanFromNow(notif.updatedAt)} + {humanFromNow(data.updatedAt)}

    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 6ee4d5da..0b0f6a63 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -11,42 +11,44 @@ import type { NotificationItemProps, NotificationListViewProps, NotificationWrap function PortalNotificationItem(props: NotificationItemProps): ReactNode { const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { + const data = props.notification; + if (data.notif.type === 'follow') { const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { + if (users.length === 2) { i18nKey = 'notifications.new_follower/two'; } - if (notif.users.length === 3) { + if (users.length === 3) { i18nKey = 'notifications.new_follower/three'; } - if (notif.users.length > 3) { + if (users.length > 3) { i18nKey = 'notifications.new_follower/multiple'; } return ( <> - +

    - + , - follower_two: + follower_one: , + follower_two: }} /> {' '} - {humanFromNow(notif.updatedAt)} + {humanFromNow(data.updatedAt)}

    @@ -54,17 +56,17 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { ); } - if (notif.type === 'notice') { + if (data.notif.type === 'system') { return ( <> - +
    - + - {notif.content} + {data.notif.content.text} {' '} - {humanFromNow(notif.updatedAt)} + {humanFromNow(data.updatedAt)} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 8cf00c93..6178de0e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -1,10 +1,10 @@ -import moment from 'moment'; import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalView'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; import type { Notification } from '@/api/generated'; @@ -24,44 +24,46 @@ export type NotificationItemProps = { function WebNotificationItem(props: NotificationItemProps): ReactNode { const url = useUrl(); const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { + const data = props.notification; + if (data.notif.type === 'follow') { const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { + if (users.length === 2) { i18nKey = 'notifications.new_follower/two'; } - if (notif.users.length === 3) { + if (users.length === 3) { i18nKey = 'notifications.new_follower/three'; } - if (notif.users.length > 3) { + if (users.length > 3) { i18nKey = 'notifications.new_follower/multiple'; } return (
    - - + + - + , - follower_two: + follower_one: , + follower_two: }} /> {' '} - {moment(notif.updatedAt).fromNow()} + {humanFromNow(data.updatedAt)} @@ -69,18 +71,18 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { ); } - if (notif.type === 'notice') { + if (data.notif.type === 'system') { return (
    - - + + - + - {notif.content} + {data.notif.content.text} {' '} - {moment(notif.updatedAt).fromNow()} + {humanFromNow(data.updatedAt)} From e94cf372cd6158c1ec63bf1c791ed5fbc3b2a000 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 16:37:31 +0200 Subject: [PATCH 061/103] feat: add postDeleted and limitedFromPosting notification type --- .../src/assets/locales/en.json | 24 ++++- .../views/ctr/notificationListView.tsx | 97 ++++++++++++++++++- .../views/portal/notificationListView.tsx | 93 +++++++++++++++++- .../views/web/notificationListView.tsx | 93 +++++++++++++++++- apps/miiverse-api/src/models/schema.prisma | 2 + .../internal/contract/notification.ts | 45 ++++++++- .../services/internal/utils/notifications.ts | 41 ++++---- 7 files changed, 351 insertions(+), 44 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index 06429d1d..831c83f9 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -97,10 +97,26 @@ }, "notifications": { "none": "No notifications.", - "new_follower/one": " followed you!", - "new_follower/two": ", followed you!", - "new_follower/three": ", , and {{count_other}} other followed you!", - "new_follower/multiple": ", , and {{count_other}} others followed you!" + "new_follower": { + "message/one": " followed you!", + "message/two": ", followed you!", + "message/three": ", , and {{count_other}} other followed you!", + "message/multiple": ", , and {{count_other}} others followed you!" + }, + "post_deleted": { + "post_removed": "Your post \"{{postId}}\" has been removed.", + "post_removed_for_reason": "Your post \"{{postId}}\" has been removed for the following reason: \"{{reason}}\".", + "comment_removed": "Your comment \"{{postId}}\" has been removed.", + "comment_removed_for_reason": "Your comment \"{{postId}}\" has been removed for the following reason: \"{{reason}}\".", + "footer": "Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum ({{contactModsUrl}})." + }, + "limited_from_posting": { + "message": "You have been Limited from Posting.", + "message_with_reason": "You have been Limited from Posting. Reason: \"{{reason}}\".", + "temporary": "You have been Limited from Posting until {{until}}.", + "temporary_with_reason": "You have been Limited from Posting until {{until}}. Reason: \"{{reason}}\".", + "footer": "Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum ({{banAppealUrl}})." + } }, "friend_requests": { "none": "No Friend Requests" diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index c06d7b0c..2cad03a9 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -1,7 +1,7 @@ import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import { CtrMiiIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrMiiIcon'; import { CtrIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrIcon'; -import { humanFromNow } from '@/util'; +import { humanDate, humanFromNow } from '@/util'; import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageTitledHeader } from '@/services/juxt-web/views/ctr/components/CtrPageHeader'; @@ -17,15 +17,15 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; if (users.length === 2) { - i18nKey = 'notifications.new_follower/two'; + i18nKey = 'notifications.new_follower.message/two'; } if (users.length === 3) { - i18nKey = 'notifications.new_follower/three'; + i18nKey = 'notifications.new_follower.message/three'; } if (users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + i18nKey = 'notifications.new_follower.message/multiple'; } return ( @@ -56,6 +56,93 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { ); } + if (data.notif.type === 'postDeleted') { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (data.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; + } + } + return ( + <> + + + + ); + } + + if (data.notif.type === 'limitedFromPosting') { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (data.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } + } + return ( + <> + + + + ); + } + if (data.notif.type === 'system') { return ( <> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 0b0f6a63..701d831d 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -1,6 +1,6 @@ import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/components/PortalNavBar'; -import { humanFromNow } from '@/util'; +import { humanDate, humanFromNow } from '@/util'; import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalMiiIcon } from '@/services/juxt-web/views/portal/components/ui/PortalMiiIcon'; @@ -17,15 +17,15 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; if (users.length === 2) { - i18nKey = 'notifications.new_follower/two'; + i18nKey = 'notifications.new_follower.message/two'; } if (users.length === 3) { - i18nKey = 'notifications.new_follower/three'; + i18nKey = 'notifications.new_follower.message/three'; } if (users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + i18nKey = 'notifications.new_follower.message/multiple'; } return ( @@ -56,6 +56,89 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { ); } + if (data.notif.type === 'postDeleted') { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (data.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; + } + } + return ( + <> + + + + ); + } + + if (data.notif.type === 'limitedFromPosting') { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (data.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } + } + return ( + <> + + + + ); + } + if (data.notif.type === 'system') { return ( <> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 6178de0e..aa3eaca9 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -4,7 +4,7 @@ import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalVie import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; -import { humanFromNow } from '@/util'; +import { humanDate, humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; import type { Notification } from '@/api/generated'; @@ -30,15 +30,15 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; if (users.length === 2) { - i18nKey = 'notifications.new_follower/two'; + i18nKey = 'notifications.new_follower.message/two'; } if (users.length === 3) { - i18nKey = 'notifications.new_follower/three'; + i18nKey = 'notifications.new_follower.message/three'; } if (users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + i18nKey = 'notifications.new_follower.message/multiple'; } return ( @@ -71,6 +71,89 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { ); } + if (data.notif.type === 'postDeleted') { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (data.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (data.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; + } + } + return ( + + ); + } + + if (data.notif.type === 'limitedFromPosting') { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (data.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (data.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } + } + return ( + + ); + } + if (data.notif.type === 'system') { return (
    diff --git a/apps/miiverse-api/src/models/schema.prisma b/apps/miiverse-api/src/models/schema.prisma index b499c981..034dc556 100644 --- a/apps/miiverse-api/src/models/schema.prisma +++ b/apps/miiverse-api/src/models/schema.prisma @@ -9,6 +9,8 @@ datasource db { enum NotificationType { Follow + LimitedFromPosting + PostDeleted System } diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 415320bd..9fcf95d8 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -1,10 +1,7 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; import type { Notification, NotificationRecipient } from '@/prisma/client'; -import type { FollowNotificationContent, SystemNotificationContent } from '@/services/internal/utils/notifications'; - -export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['system', 'follow'])); -export type NotificationType = z.infer; +import type { FollowNotificationContent, LimitedFromPostingNotificationContent, PostDeletedNotificationContent, SystemNotificationContent } from '@/services/internal/utils/notifications'; export const followNotificationSchema = asOpenapi('FollowNotification', z.object({ type: z.literal('follow'), @@ -25,13 +22,32 @@ export const systemNotificationSchema = asOpenapi('SystemNotification', z.object }) })); +export const postDeletedNotificationSchema = asOpenapi('PostDeletedNotification', z.object({ + type: z.literal('postDeleted'), + content: z.object({ + postId: z.string(), + reason: z.string().optional(), + postType: z.enum(['comment', 'post']) + }) +})); + +export const limitedFromPostingNotificationSchema = asOpenapi('LimitedFromPostingNotification', z.object({ + type: z.literal('limitedFromPosting'), + content: z.object({ + reason: z.string().optional(), + until: z.date().optional() + }) +})); + export const notificationSchema = z.object({ pid: z.number(), hasRead: z.boolean(), updatedAt: z.date(), notif: z.discriminatedUnion('type', [ followNotificationSchema, - systemNotificationSchema + systemNotificationSchema, + postDeletedNotificationSchema, + limitedFromPostingNotificationSchema ]) }).openapi('Notification'); @@ -61,6 +77,25 @@ export function mapNotification(recipient: NotificationRecipient, notif: Notific }; } + if (notif.type === 'LimitedFromPosting') { + const content = notif.content as LimitedFromPostingNotificationContent; + data = { + type: 'limitedFromPosting', + content: { + reason: content.reason, + until: content.until ? new Date(content.until) : undefined + } + }; + } + + if (notif.type === 'PostDeleted') { + const content = notif.content as PostDeletedNotificationContent; + data = { + type: 'postDeleted', + content + }; + } + if (!data) { throw new Error(`No DTO mapping for notification ${notif.type} found`); } diff --git a/apps/miiverse-api/src/services/internal/utils/notifications.ts b/apps/miiverse-api/src/services/internal/utils/notifications.ts index 84543946..cc98c90f 100644 --- a/apps/miiverse-api/src/services/internal/utils/notifications.ts +++ b/apps/miiverse-api/src/services/internal/utils/notifications.ts @@ -1,4 +1,3 @@ -import { humanDate } from '@/services/internal/utils/dates'; import { genId } from '@/util'; import type { PrismaClient } from '@/prisma/client'; import type { IPost } from '@/types/mongoose/post'; @@ -22,7 +21,7 @@ export type LimitedPostingNotificationOptions = { export type FollowNotificationContent = { users: { - timestamp: string; // Iso timestamp + timestamp: string; // ISO timestamp pid: number; }[]; }; @@ -33,6 +32,17 @@ export type SystemNotificationContent = { text: string; }; +export type PostDeletedNotificationContent = { + postId: string; + reason?: string; + postType: 'comment' | 'post'; +}; + +export type LimitedFromPostingNotificationContent = { + reason?: string; + until?: string; // ISO timestamp, +}; + export async function createNewFollowNotification(db: PrismaClient, ops: FollowNotificationOptions): Promise { const now = new Date(); @@ -124,20 +134,16 @@ export async function createNewFollowNotification(db: PrismaClient, ops: FollowN } export async function createNewPostDeletionNotification(db: PrismaClient, ops: PostDeletionNotificationOptions): Promise { - const postType = ops.post.parent ? 'comment' : 'post'; - const content: SystemNotificationContent = { - imagePath: '/images/bandwidthalert.png', - link: '/titles/2551084080/new', - text: `Your ${postType} "${ops.post.id}" has been removed` + - (ops.reason ? ` for the following reason: "${ops.reason}". ` : '. ') + - `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/).` + const content: PostDeletedNotificationContent = { + postId: ops.post.id, + reason: ops.reason, + postType: ops.post.parent ? 'comment' : 'post' }; await db.notification.create({ data: { id: genId(), content, - type: 'System', + type: 'PostDeleted', notificationRecipients: { create: { id: genId(), @@ -149,20 +155,15 @@ export async function createNewPostDeletionNotification(db: PrismaClient, ops: P } export async function createNewLimitedPostingNotification(db: PrismaClient, ops: LimitedPostingNotificationOptions): Promise { - const firstSentence = ops.banLiftDate ? `You have been Limited from Posting until ${humanDate(ops.banLiftDate)}. ` : `You have been Limited from Posting. `; - const content: SystemNotificationContent = { - imagePath: '/images/bandwidthalert.png', - link: '/titles/2551084080/new', - text: firstSentence + - (ops.reason ? `Reason: "${ops.reason}". ` : '') + - `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/).` + const content: LimitedFromPostingNotificationContent = { + until: ops.banLiftDate?.toISOString() ?? undefined, + reason: ops.reason ?? undefined }; await db.notification.create({ data: { id: genId(), content, - type: 'System', + type: 'LimitedFromPosting', notificationRecipients: { create: { id: genId(), From dee59d6a680e260807b1fbc8723cc263c42d2ed8 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 16:45:46 +0200 Subject: [PATCH 062/103] chore: fixed docker compose using wrong init --- .docker/docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index d9812cac..81e5cda2 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - redis_data:/data postgres: - image: postgres + image: postgres:18 restart: unless-stopped ports: - "5432:5432" @@ -18,8 +18,8 @@ services: POSTGRES_USER: "postgres" POSTGRES_DB: "postgres" volumes: - - "postgres_data:/var/lib/postgresql" - - ./create-dbs:/docker-entrypoint-initdb.d/init.sql + - "db_data:/var/lib/postgresql" + - ./create-dbs.sql:/docker-entrypoint-initdb.d/init.sql mongo: image: mongo:8.0 restart: unless-stopped @@ -138,8 +138,8 @@ services: PN_FRIENDS_ACCOUNT_GRPC_API_KEY: "12345678123456781234567812345678" volumes: - postgres_data: mongo_data: mongo_config: redis_data: minio_data: + db_data: From 02961fb3a0805f080ea1ff893dc0b2abffd2cc0f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 16:52:36 +0200 Subject: [PATCH 063/103] fix: add post ID to post deletion i18n keys --- .../src/services/juxt-web/views/ctr/notificationListView.tsx | 1 + .../src/services/juxt-web/views/portal/notificationListView.tsx | 1 + .../src/services/juxt-web/views/web/notificationListView.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index 2cad03a9..04750ff8 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -77,6 +77,7 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 701d831d..c50dc23a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -76,6 +76,7 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index aa3eaca9..2c8409f2 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -92,6 +92,7 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { From 5b81cb954d190f737903a9a888e8441757768e7c Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 16:58:15 +0200 Subject: [PATCH 064/103] fix: fix invalid date rendering on limitedFromPosting notifications --- .../src/services/juxt-web/views/ctr/notificationListView.tsx | 2 +- .../src/services/juxt-web/views/portal/notificationListView.tsx | 2 +- .../src/services/juxt-web/views/web/notificationListView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index 04750ff8..420ea99c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -121,7 +121,7 @@ function CtrNotificationItem(props: NotificationItemProps): ReactNode { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index c50dc23a..227acbe0 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -118,7 +118,7 @@ function PortalNotificationItem(props: NotificationItemProps): ReactNode { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 2c8409f2..69a81589 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -134,7 +134,7 @@ function WebNotificationItem(props: NotificationItemProps): ReactNode { From 803633a855a7dd091e1a3145f9e3972938af9f7e Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:08:17 +0200 Subject: [PATCH 065/103] feat: add database migration --- .../migration.sql | 29 +++++++++++++++++++ .../src/models/migrations/migration_lock.toml | 3 ++ 2 files changed, 32 insertions(+) create mode 100644 apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql create mode 100644 apps/miiverse-api/src/models/migrations/migration_lock.toml diff --git a/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql b/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql new file mode 100644 index 00000000..4085bc79 --- /dev/null +++ b/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql @@ -0,0 +1,29 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('Follow', 'LimitedFromPosting', 'PostDeleted', 'System'); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "content" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_recipients" ( + "id" TEXT NOT NULL, + "pid" INTEGER NOT NULL, + "has_read" BOOLEAN NOT NULL DEFAULT false, + "notification_id" TEXT NOT NULL, + + CONSTRAINT "notification_recipients_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notification_recipients_pid_idx" ON "notification_recipients"("pid"); + +-- AddForeignKey +ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/miiverse-api/src/models/migrations/migration_lock.toml b/apps/miiverse-api/src/models/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/apps/miiverse-api/src/models/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" From ed8d457ab5e2aefdabf1b25f196c25cb19c9008f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:32:51 +0200 Subject: [PATCH 066/103] chore: update all new_follower translations to the new path --- apps/juxtaposition-ui/src/assets/locales/cs.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/de.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/es.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/fr.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/he.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/hr.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/hu.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/id.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/it.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/ko.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/nl.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/pl.json | 10 ++++++---- apps/juxtaposition-ui/src/assets/locales/pt.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/pt_PT.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/pt_br.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/sk.json | 12 +++++++----- apps/juxtaposition-ui/src/assets/locales/zh.json | 12 +++++++----- .../juxtaposition-ui/src/assets/locales/zh_Hant.json | 12 +++++++----- 18 files changed, 125 insertions(+), 89 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/cs.json b/apps/juxtaposition-ui/src/assets/locales/cs.json index ee2441bc..3fb0d0ad 100644 --- a/apps/juxtaposition-ui/src/assets/locales/cs.json +++ b/apps/juxtaposition-ui/src/assets/locales/cs.json @@ -75,10 +75,12 @@ "language": "Čeština", "notifications": { "none": "Žádná oznámení.", - "new_follower/one": " Vás začali sledovat!", - "new_follower/two": ", Vás začali sledovat!", - "new_follower/three": ", , a {{count_other}} dalších Vás začali sledovat!", - "new_follower/multiple": ", , a {{count_other}} dalších Vás začali sledovat!" + "new_follower": { + "message/one": " Vás začali sledovat!", + "message/two": ", Vás začali sledovat!", + "message/three": ", , a {{count_other}} dalších Vás začali sledovat!", + "message/multiple": ", , a {{count_other}} dalších Vás začali sledovat!" + } }, "new_post": { "swearing": "Příspěvek nesmí obsahovat nevhodné výrazy.", @@ -194,4 +196,4 @@ "silently_delete_post": "Potichu smazat", "moderate_user": "Moderovat uživatele" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/de.json b/apps/juxtaposition-ui/src/assets/locales/de.json index 038fda7b..3b926136 100644 --- a/apps/juxtaposition-ui/src/assets/locales/de.json +++ b/apps/juxtaposition-ui/src/assets/locales/de.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Keine Benachrichtigungen.", - "new_follower/one": " folgt dir jetzt!", - "new_follower/two": " und folgen dir jetzt!", - "new_follower/three": ", und {{count_other}} weitere folgen dir jetzt!", - "new_follower/multiple": ", und {{count_other}} weitere folgen dir jetzt!" + "new_follower": { + "message/one": " folgt dir jetzt!", + "message/two": " und folgen dir jetzt!", + "message/three": ", und {{count_other}} weitere folgen dir jetzt!", + "message/multiple": ", und {{count_other}} weitere folgen dir jetzt!" + } }, "new_post": { "post_to": "Beitrag posten bei {{user}}", @@ -185,4 +187,4 @@ "silently_delete_post": "Geräuschlos löschen", "moderate_user": "Nutzer moderieren" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/es.json b/apps/juxtaposition-ui/src/assets/locales/es.json index f84f6cee..ea7387f6 100644 --- a/apps/juxtaposition-ui/src/assets/locales/es.json +++ b/apps/juxtaposition-ui/src/assets/locales/es.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Sin notificaciones.", - "new_follower/one": "¡ te ha seguido!", - "new_follower/two": "¡ y te han seguido!", - "new_follower/three": "¡, , y {{count_other}} más te han seguido!", - "new_follower/multiple": "¡, , y otros {{count_other}} te han seguido!" + "new_follower": { + "message/one": "¡ te ha seguido!", + "message/two": "¡ y te han seguido!", + "message/three": "¡, , y {{count_other}} más te han seguido!", + "message/multiple": "¡, , y otros {{count_other}} te han seguido!" + } }, "new_post": { "post_to": "Mensaje para \"{{user}}\"", @@ -185,4 +187,4 @@ "title": "Moderación", "silently_delete_post": "Eliminar en silencio" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/fr.json b/apps/juxtaposition-ui/src/assets/locales/fr.json index 01d7256b..b7659c78 100644 --- a/apps/juxtaposition-ui/src/assets/locales/fr.json +++ b/apps/juxtaposition-ui/src/assets/locales/fr.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Aucune notification.", - "new_follower/one": " vous suit !", - "new_follower/two": " et vous suivent !", - "new_follower/three": ", et {{count_other}} autre personne vous suivent !", - "new_follower/multiple": ", et {{count_other}} autres personnes vous suivent !" + "new_follower": { + "message/one": " vous suit !", + "message/two": " et vous suivent !", + "message/three": ", et {{count_other}} autre personne vous suivent !", + "message/multiple": ", et {{count_other}} autres personnes vous suivent !" + } }, "new_post": { "post_to": "Envoyer à {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Supprimer discrètement", "moderate_user": "Modérer l'utilisateur" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/he.json b/apps/juxtaposition-ui/src/assets/locales/he.json index a8f6e14c..5629a7cd 100644 --- a/apps/juxtaposition-ui/src/assets/locales/he.json +++ b/apps/juxtaposition-ui/src/assets/locales/he.json @@ -109,10 +109,12 @@ }, "notifications": { "none": "אין התראות.", - "new_follower/one": "‏ עקב אחריכם!", - "new_follower/two": "‏,‏‏ עקבו אחריכם!", - "new_follower/three": "‏, ‏ ו-{{count_other}} נוסף עקבו אחריכם!", - "new_follower/multiple": "‏, ‏ ו-{{count_other}} נוספים עקבו אחריכם!" + "new_follower": { + "message/one": "‏ עקב אחריכם!", + "message/two": "‏,‏‏ עקבו אחריכם!", + "message/three": "‏, ‏ ו-{{count_other}} נוסף עקבו אחריכם!", + "message/multiple": "‏, ‏ ו-{{count_other}} נוספים עקבו אחריכם!" + } }, "new_post": { "swearing": "הפוסט לא יכול להכיל שפה בוטה.", @@ -184,4 +186,4 @@ "title": "ניהול", "silently_delete_post": "מחיקה שקטה" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/hr.json b/apps/juxtaposition-ui/src/assets/locales/hr.json index fdeae6c1..0cf56693 100644 --- a/apps/juxtaposition-ui/src/assets/locales/hr.json +++ b/apps/juxtaposition-ui/src/assets/locales/hr.json @@ -69,10 +69,12 @@ }, "notifications": { "none": "Nema obavijesti.", - "new_follower/one": " te je pratiol/la!", - "new_follower/two": ", su te pratili!", - "new_follower/three": ", i {{count_other}} druga pratitelja su te pratili!", - "new_follower/multiple": ", i {{count_other}} drugih pratitelja su te pratili!" + "new_follower": { + "message/one": " te je pratiol/la!", + "message/two": ", su te pratili!", + "message/three": ", i {{count_other}} druga pratitelja su te pratili!", + "message/multiple": ", i {{count_other}} drugih pratitelja su te pratili!" + } }, "new_post": { "post_to": "Objava za {{user}}", @@ -189,4 +191,4 @@ "silently_delete_post": "Tiho brisanje", "moderate_user": "Moderiraj korisnika" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/hu.json b/apps/juxtaposition-ui/src/assets/locales/hu.json index 50a62498..4a66843d 100644 --- a/apps/juxtaposition-ui/src/assets/locales/hu.json +++ b/apps/juxtaposition-ui/src/assets/locales/hu.json @@ -74,10 +74,12 @@ }, "notifications": { "none": "Nincsennek értesítések.", - "new_follower/one": " bekövetett téged!", - "new_follower/two": ", bekövetett téged!", - "new_follower/three": ", és {{count_other}} további felhasználó bekövetett téged!", - "new_follower/multiple": ", és {{count_other}} további felhasználó bekövetett téged!" + "new_follower": { + "message/one": " bekövetett téged!", + "message/two": ", bekövetett téged!", + "message/three": ", és {{count_other}} további felhasználó bekövetett téged!", + "message/multiple": ", és {{count_other}} további felhasználó bekövetett téged!" + } }, "new_post": { "post_to": "Bejegyzés ehhez {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Törlés csendben", "moderate_user": "Felhasználó moderálása" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/id.json b/apps/juxtaposition-ui/src/assets/locales/id.json index 49985412..464aeeb7 100644 --- a/apps/juxtaposition-ui/src/assets/locales/id.json +++ b/apps/juxtaposition-ui/src/assets/locales/id.json @@ -120,10 +120,12 @@ }, "notifications": { "none": "Tidak ada notificasi.", - "new_follower/one": " mengikuti Anda!", - "new_follower/two": ", mengikuti Anda!", - "new_follower/three": ", , dan {{count_other}} lainnya mengikuti Anda!", - "new_follower/multiple": ", , dan {{count_other}} lainnya mengikuti Anda!" + "new_follower": { + "message/one": " mengikuti Anda!", + "message/two": ", mengikuti Anda!", + "message/three": ", , dan {{count_other}} lainnya mengikuti Anda!", + "message/multiple": ", , dan {{count_other}} lainnya mengikuti Anda!" + } }, "new_post": { "post_to": "Posting ke {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Hapus diam-diam", "moderate_user": "Moderasi Pengguna" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/it.json b/apps/juxtaposition-ui/src/assets/locales/it.json index 7a93860a..3f9d740e 100644 --- a/apps/juxtaposition-ui/src/assets/locales/it.json +++ b/apps/juxtaposition-ui/src/assets/locales/it.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Nessuna notifica.", - "new_follower/one": " ti segue!", - "new_follower/two": " e ti seguono!", - "new_follower/three": ", e {{count_other}} altro ti seguono!", - "new_follower/multiple": ", e altri {{count_other}} ti seguono!" + "new_follower": { + "message/one": " ti segue!", + "message/two": " e ti seguono!", + "message/three": ", e {{count_other}} altro ti seguono!", + "message/multiple": ", e altri {{count_other}} ti seguono!" + } }, "new_post": { "post_to": "Posta su {{user}}", @@ -185,4 +187,4 @@ "silently_delete_post": "Elimina senza notifica", "moderate_user": "Modera utente" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/ko.json b/apps/juxtaposition-ui/src/assets/locales/ko.json index 97c55aba..c8519e14 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ko.json +++ b/apps/juxtaposition-ui/src/assets/locales/ko.json @@ -61,10 +61,12 @@ }, "notifications": { "none": "알림 없음.", - "new_follower/one": "이(가) 당신을 팔로우했어요!", - "new_follower/two": ", 이(가) 당신을 팔로우했어요!", - "new_follower/three": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!", - "new_follower/multiple": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!" + "new_follower": { + "message/one": "이(가) 당신을 팔로우했어요!", + "message/two": ", 이(가) 당신을 팔로우했어요!", + "message/three": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!", + "message/multiple": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!" + } }, "new_post": { "post_to": "{{user}}에게 게시물 달기", @@ -152,4 +154,4 @@ "reason_inappropiate_ingame": "게임에서의 부적절한 행위", "reason_missing_images": "이미지가 없음" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/nl.json b/apps/juxtaposition-ui/src/assets/locales/nl.json index 325b5d00..fcf02041 100644 --- a/apps/juxtaposition-ui/src/assets/locales/nl.json +++ b/apps/juxtaposition-ui/src/assets/locales/nl.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Geen meldingen.", - "new_follower/one": " volgt jou!", - "new_follower/two": ", volgen jou!", - "new_follower/three": ", , and {{count_other}} volgen jou!", - "new_follower/multiple": ", , en {{count_other}} volgen jou!" + "new_follower": { + "message/one": " volgt jou!", + "message/two": ", volgen jou!", + "message/three": ", , and {{count_other}} volgen jou!", + "message/multiple": ", , en {{count_other}} volgen jou!" + } }, "new_post": { "post_to": "Post in {{user}}", @@ -185,4 +187,4 @@ "silently_delete_post": "Stilzwijgend verwijderen", "moderate_user": "Gemiddelde gebruiker" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pl.json b/apps/juxtaposition-ui/src/assets/locales/pl.json index 58ea4cb0..27a8cd78 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pl.json +++ b/apps/juxtaposition-ui/src/assets/locales/pl.json @@ -117,9 +117,11 @@ }, "notifications": { "none": "Brak powiadomień.", - "new_follower/one": " obserwuje cię!", - "new_follower/two": ", obserwują cię!", - "new_follower/three": ", i {{count_other}} innych obserwuje cię!" + "new_follower": { + "message/one": " obserwuje cię!", + "message/two": ", obserwują cię!", + "message/three": ", i {{count_other}} innych obserwuje cię!" + } }, "new_post": { "post_to": "Postuj do {{user}}", @@ -148,4 +150,4 @@ "yeahs_count/one": "Osoby, które zareagowały \"Nieźle\": ", "yeahs_count/multiple": "Osoby, które zareagowały \"Nieźle\": " } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt.json b/apps/juxtaposition-ui/src/assets/locales/pt.json index 82d2639d..024aaa9f 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu-o!", - "new_follower/two": ", seguiram-lo!", - "new_follower/three": ", e {{count_other}} outro seguiram-o!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram-o!" + "new_follower": { + "message/one": " seguiu-o!", + "message/two": ", seguiram-lo!", + "message/three": ", e {{count_other}} outro seguiram-o!", + "message/multiple": ", , e {{count_other}} outros seguiram-o!" + } }, "new_post": { "post_to": "Publicar em {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Apagar silenciosamente", "moderate_user": "Moderar Utilizador" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt_PT.json b/apps/juxtaposition-ui/src/assets/locales/pt_PT.json index 36b8dc68..c2e463a0 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt_PT.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt_PT.json @@ -65,10 +65,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu-o!", - "new_follower/two": ", seguiram-lo!", - "new_follower/three": ", e {{count_other}} outro seguiram-o!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram-o!" + "new_follower": { + "message/one": " seguiu-o!", + "message/two": ", seguiram-lo!", + "message/three": ", e {{count_other}} outro seguiram-o!", + "message/multiple": ", , e {{count_other}} outros seguiram-o!" + } }, "setup": { "rules_text": { @@ -194,4 +196,4 @@ "silently_delete_post": "Apagar silenciosamente", "moderate_user": "Moderar Utilizador" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt_br.json b/apps/juxtaposition-ui/src/assets/locales/pt_br.json index b55d7817..d0d20030 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt_br.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt_br.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu você!", - "new_follower/two": ", seguiram você!", - "new_follower/three": ", , e {{count_other}} outro seguiram você!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram você!" + "new_follower": { + "message/one": " seguiu você!", + "message/two": ", seguiram você!", + "message/three": ", , e {{count_other}} outro seguiram você!", + "message/multiple": ", , e {{count_other}} outros seguiram você!" + } }, "new_post": { "post_to": "Postar em {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Deletar silenciosamente", "moderate_user": "Moderar Usuário" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/sk.json b/apps/juxtaposition-ui/src/assets/locales/sk.json index aab1f494..f38157dd 100644 --- a/apps/juxtaposition-ui/src/assets/locales/sk.json +++ b/apps/juxtaposition-ui/src/assets/locales/sk.json @@ -68,10 +68,12 @@ }, "notifications": { "none": "Žiadne upozornenia.", - "new_follower/one": " Vás začal/-a sledovať!", - "new_follower/two": ", Vás začali sledovať!", - "new_follower/three": ", , a {{count_other}} ďalší Vás začali sledovať!", - "new_follower/multiple": ", , a {{count_other}} ďalších Vás začali sledovať!" + "new_follower": { + "message/one": " Vás začal/-a sledovať!", + "message/two": ", Vás začali sledovať!", + "message/three": ", , a {{count_other}} ďalší Vás začali sledovať!", + "message/multiple": ", , a {{count_other}} ďalších Vás začali sledovať!" + } }, "new_post": { "swearing": "Príspevok nemôže obsahovať nevhodné výrazy.", @@ -185,4 +187,4 @@ "silently_delete_post": "Ticho vymazať", "moderate_user": "Moderovať Používateľa" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/zh.json b/apps/juxtaposition-ui/src/assets/locales/zh.json index 8d818457..e8d63809 100644 --- a/apps/juxtaposition-ui/src/assets/locales/zh.json +++ b/apps/juxtaposition-ui/src/assets/locales/zh.json @@ -73,10 +73,12 @@ }, "notifications": { "none": "无通知。", - "new_follower/one": " 已开始关注你!", - "new_follower/two": " 已开始关注你!", - "new_follower/three": " 和 {{count_other}} 位其他用户开始关注你!", - "new_follower/multiple": " 和 {{count_other}} 位其他用户开始关注你!" + "new_follower": { + "message/one": " 已开始关注你!", + "message/two": " 已开始关注你!", + "message/three": " 和 {{count_other}} 位其他用户开始关注你!", + "message/multiple": " 和 {{count_other}} 位其他用户开始关注你!" + } }, "new_post": { "post_to": "发布到{{user}}", @@ -189,4 +191,4 @@ "silently_delete_post": "静默删除", "moderate_user": "审核用户" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json b/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json index b21b32b7..abb49535 100644 --- a/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json +++ b/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json @@ -115,10 +115,12 @@ }, "notifications": { "none": "沒有通知。", - "new_follower/one": " 已開始追蹤你!", - "new_follower/two": " 已開始追蹤你!", - "new_follower/three": " 和另外 {{count_other}} 人已開始追蹤你!", - "new_follower/multiple": " 和另外 {{count_other}} 人已開始追蹤你!" + "new_follower": { + "message/one": " 已開始追蹤你!", + "message/two": " 已開始追蹤你!", + "message/three": " 和另外 {{count_other}} 人已開始追蹤你!", + "message/multiple": " 和另外 {{count_other}} 人已開始追蹤你!" + } }, "new_post": { "swearing": "貼文不能包含露骨內容。", @@ -189,4 +191,4 @@ "silently_delete_post": "靜默刪除", "moderate_user": "審核此使用者" } -} +} \ No newline at end of file From db01ccccc544187aefa2a1cbb771faddbd5a948f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:34:28 +0200 Subject: [PATCH 067/103] chore: add postinstall for prisma types --- apps/miiverse-api/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/miiverse-api/package.json b/apps/miiverse-api/package.json index c62b806e..cfe2445c 100644 --- a/apps/miiverse-api/package.json +++ b/apps/miiverse-api/package.json @@ -14,7 +14,8 @@ "migration:reset": "prisma migrate reset", "db:seed": "tsup && node --enable-source-maps dist/seed.entry.mjs", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "postinstall": "prisma generate" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", From fc8d753aa48b5cd3830c565988414a00697dea1d Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:38:14 +0200 Subject: [PATCH 068/103] chore: allow prisma generate to run without config --- apps/miiverse-api/prisma.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/miiverse-api/prisma.config.ts b/apps/miiverse-api/prisma.config.ts index 10eb3d24..0195e953 100644 --- a/apps/miiverse-api/prisma.config.ts +++ b/apps/miiverse-api/prisma.config.ts @@ -9,7 +9,7 @@ import type { SchemaTransformer, SchemaTransformerContext } from '@neato/config' const schema = z.object({ db: z.object({ - url: z.string() + url: z.string().optional() }) }); From 7ff7f33428fa3d4ce98c00e44600eb559afcf859 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:39:54 +0200 Subject: [PATCH 069/103] chore: made db connection url as optional --- apps/miiverse-api/prisma.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/miiverse-api/prisma.config.ts b/apps/miiverse-api/prisma.config.ts index 0195e953..a2145aec 100644 --- a/apps/miiverse-api/prisma.config.ts +++ b/apps/miiverse-api/prisma.config.ts @@ -10,7 +10,7 @@ import type { SchemaTransformer, SchemaTransformerContext } from '@neato/config' const schema = z.object({ db: z.object({ url: z.string().optional() - }) + }).prefault({}) }); const dockerPreset: z.input = { From 12f12161092e12dab18044af95aeb29a9efc5e2a Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 17:42:44 +0200 Subject: [PATCH 070/103] chore: remove bundle analysis CI, it's broken --- .github/workflows/bundle-analysis.yml | 37 ------------------------- .github/workflows/bundle-build.yml | 40 --------------------------- 2 files changed, 77 deletions(-) delete mode 100644 .github/workflows/bundle-analysis.yml delete mode 100644 .github/workflows/bundle-build.yml diff --git a/.github/workflows/bundle-analysis.yml b/.github/workflows/bundle-analysis.yml deleted file mode 100644 index 92f42883..00000000 --- a/.github/workflows/bundle-analysis.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Bundle analysis - -# This is intentionall split into two parts: -# - bundle-build (builds untrusted code securely and stores metafiles) -# - bundle-analysis (reads metafiles and comments, no untrusted code) -# This is to prevent untrusted code being ran with a token that has write access to PRs - -on: - workflow_run: - workflows: ["Compile bundle analysis"] - types: - - completed - -jobs: - analyze: - name: Analyze bundle - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - pull-requests: write - if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' - steps: - - name: Download artifact from build workflow - uses: actions/download-artifact@v7 - with: - name: bundle-metafiles - path: metafiles - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Analyze esbuild bundle size - uses: exoego/esbuild-bundle-analyzer@v1 - with: - metafiles: "metafiles/**/metafile-*.json" diff --git a/.github/workflows/bundle-build.yml b/.github/workflows/bundle-build.yml deleted file mode 100644 index 3fa66d58..00000000 --- a/.github/workflows/bundle-build.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Compile bundle analysis - -on: - pull_request: - -jobs: - compile: - name: Compile - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read # for checkout repository - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Fetch the commit SHA of the forked PR - ref: "${{ github.event.pull_request.merge_commit_sha }}" - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - - - name: Install dependencies - run: npm ci - - - name: Build miiverse-api - run: npm run build - working-directory: ./apps/miiverse-api - - - name: Build juxtaposition-ui with metafiles - run: npm run build:meta - working-directory: ./apps/juxtaposition-ui - - - name: Upload metafiles artifact - uses: actions/upload-artifact@v7 - with: - name: bundle-metafiles - path: apps/juxtaposition-ui/dist/webfiles/*/metafile-*.json From 749f28bffb6a45011d61eb85e552ea607c59b2b1 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 20:50:52 +0200 Subject: [PATCH 071/103] feat: add data migration for notifications --- .../8-notification-to-pg/package-lock.json | 323 ++++++++++++++++++ migrations/8-notification-to-pg/package.json | 11 + migrations/8-notification-to-pg/script.ts | 262 ++++++++++++++ 3 files changed, 596 insertions(+) create mode 100644 migrations/8-notification-to-pg/package-lock.json create mode 100644 migrations/8-notification-to-pg/package.json create mode 100644 migrations/8-notification-to-pg/script.ts diff --git a/migrations/8-notification-to-pg/package-lock.json b/migrations/8-notification-to-pg/package-lock.json new file mode 100644 index 00000000..54bd4df5 --- /dev/null +++ b/migrations/8-notification-to-pg/package-lock.json @@ -0,0 +1,323 @@ +{ + "name": "8-notification-to-pg", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "dotenv": "^17.4.2", + "mongodb": "^7.4.0", + "pg": "^8.22.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mongodb": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.4.0.tgz", + "integrity": "sha512-giySkkdYiwoBFo/oCc8nzov3xOYZ/sB8OpAYk5GINRLEjVw0LDsm8xgQL0XMTyU4extQlDZjhdUr1ZEwKFaazw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/migrations/8-notification-to-pg/package.json b/migrations/8-notification-to-pg/package.json new file mode 100644 index 00000000..925e97f2 --- /dev/null +++ b/migrations/8-notification-to-pg/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "scripts": { + "start": "node ./script.ts" + }, + "dependencies": { + "dotenv": "^17.4.2", + "mongodb": "^7.4.0", + "pg": "^8.22.0" + } +} diff --git a/migrations/8-notification-to-pg/script.ts b/migrations/8-notification-to-pg/script.ts new file mode 100644 index 00000000..0434953e --- /dev/null +++ b/migrations/8-notification-to-pg/script.ts @@ -0,0 +1,262 @@ +import 'dotenv/config' +import { MongoClient, type Document } from "mongodb"; +import { Client as PgClient } from "pg"; +import { randomUUID } from "node:crypto"; + +const MONGO_URI = process.env.MONGO_URI; +const POSTGRES_URL = process.env.POSTGRES_URL; + +if (!MONGO_URI || !POSTGRES_URL) { + console.error("Missing MONGO_URI or POSTGRES_URL"); + process.exit(1); +} + +const mongo = new MongoClient(MONGO_URI); +await mongo.connect(); + +const db = mongo.db(); +const notifications = db.collection("notifications"); + +const pg = new PgClient({ + connectionString: POSTGRES_URL, +}); +await pg.connect(); + +function createMetaFromDoc(doc: Document): { type: string, content: Record} | null { + if (doc.type === 'follow') { + const users = doc.users.map((v: any) => ({ + timestamp: v.timestamp, + pid: Number(v.user), + })); + users.push({ + timestamp: doc.lastUpdated, + pid: Number(doc.objectID), + }) + return { + type: 'Follow', + content: { + users, + } + } + } + + if (doc.type === 'notice') { + const text = doc.text; + + // post removal with reason + // Formats: + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason" + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + // * Your comment "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + if (text.startsWith("Your") && text.includes("has been removed for")) { + const postType = text.startsWith("Your comment") ? 'comment' : 'post'; + if (postType === 'post' && !text.startsWith("Your post")) { + console.warn(`Could not determine post type ${doc._id}: "${text}"`) + return null; + } + const [_,__,postId,reason] = text.match(/your (post|comment) "([^"]+)" [\s\S]*reason: "([\s\S]*)"(\.? ?Click this message |$)/i) + if (!postId || !reason) { + console.warn(`Could not extract reason and postId ${doc._id}: "${text}"`) + return null; + } + return { + type: 'PostDeleted', + content: { + postId, + reason, + postType, + } + } + } + + // post removal without reason + // Formats: + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + // * Your comment "hKikmHAKf8c8RFCK2avCn" has been removed. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + if (text.startsWith("Your") && text.includes("has been removed")) { + const postType = text.startsWith("Your comment") ? 'comment' : 'post'; + if (postType === 'post' && !text.startsWith("Your post")) { + console.warn(`Could not determine post type ${doc._id}: "${text}"`) + return null; + } + const [_,__,postId] = text.match(/your (post|comment) "([^"]*)"/i) + if (!postId) { + console.warn(`Could not extract postId ${doc._id}: "${text}"`) + return null; + } + return { + type: 'PostDeleted', + content: { + postId, + reason: undefined, + postType, + } + } + } + + // Limited from posting with date + // Formats: + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Reason: "test reason".Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Wed Jun 11 2025 23:44:00 GMT+0000. Reason: "test reason". If you have any questions contact the moderators in the Discord server or forum + // Dates can be "null" or "Invalid date" as well + if (text.toLowerCase().includes("limited from posting until")) { + const [_,dateStr] = text.match(/Posting until ([^.]+)\./i); + if (!dateStr) { + console.warn(`Could not extract date ${doc._id}: "${text}"`) + return null; + } + + let reason: undefined | string; + if (text.includes("Reason:")) { + const [_,reason] = text.match(/Reason: "([\s\S]*)"\.? ?(Click this message|If you have any questions)/i); + if (reason === undefined) { + console.warn(`Could not extract reason ${doc._id}: "${text}"`) + return null; + } + } + + let date: Date; + if (dateStr === "null") date = new Date(); + else if (dateStr === "Invalid date") date = new Date(); + else date = new Date(dateStr) + + return { + type: 'LimitedFromPosting', + content: { + until: date.toISOString(), + reason, + } + } + } + + // Limited from posting without date + // formats: + // * You have been Limited from Posting. Reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting. Reason: "test reason".Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + if (text.toLowerCase().includes("limited from posting")) { + let reason: undefined | string; + if (text.includes("Reason:")) { + const [_,reason] = text.match(/Reason: "([\s\S]*)"\.? ?(Click this message|If you have any questions)/i); + if (reason === undefined) { + console.warn(`Could not extract reason ${doc._id}: "${text}"`) + return null; + } + } + + return { + type: 'LimitedFromPosting', + content: { + until: undefined, + reason, + } + } + } + } + + if (doc.type === 'notice') { + console.warn(`Recieved generic notice for notification ${doc._id}: "${doc.text}"`) + return { + type: 'System', + content: { + text: doc.text, + link: doc.link, + imagePath: doc.image, + } + } + } + + return null; // Unknown type +} + +async function main() { + console.log("Starting migration"); + const cursor = notifications.find({}); + let migrated = 0; + + while (await cursor.hasNext()) { + const notification = await cursor.next(); + if (!notification) { + console.warn(`Skipping document: Received null`); + continue; + } + console.log(`Processing ${notification._id}`); + + let meta; + try { + meta = createMetaFromDoc(notification); + } catch (err) { + console.warn(err, `Skipping ${notification._id}: Content extraction broke: ${notification.text}`); + continue; + } + if (!meta) { + console.warn(`Skipping ${notification._id}: No content found`); + continue; + } + + try { + await pg.query("BEGIN"); + + const notifId = randomUUID(); + const createdAt = notification._id.getTimestamp() + await pg.query( + ` + INSERT INTO notifications ( + id, + type, + content, + created_at, + updated_at + ) + VALUES ($1, $2, $3::jsonb, $4, $5) + `, + [ + notifId, + meta.type, + JSON.stringify(meta.content), + createdAt, + notification.lastUpdated ?? createdAt, + ] + ); + + await pg.query( + ` + INSERT INTO notification_recipients ( + id, + pid, + has_read, + notification_id + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING + `, + [ + randomUUID(), + Number(notification.pid), + notification.read ?? false, + notifId, + ] + ); + + await pg.query("COMMIT"); + + migrated++; + } catch (err) { + await pg.query("ROLLBACK"); + console.error(`Failed to migrate notification ${notification._id}`, err); + } + } + + console.log(`Done. Migrated ${migrated} notifications.`); + +} + +await main().catch((err) => { + console.error(err); + process.exit(1); +}); + +await pg.end(); +await mongo.close(); From 8483e0bfd905e256ce9b93c9339bfbf532bb713b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 21:12:24 +0200 Subject: [PATCH 072/103] chore: clean up exports on fields that only locally used --- .../src/services/juxt-web/routes/admin/adminAutomod.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx index 769828bc..c4cd7376 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx @@ -8,11 +8,8 @@ import { onOffSchema } from '@/services/juxt-web/routes/admin/admin'; import type { AutomodRuleListViewProps } from '@/services/juxt-web/views/web/admin/automodRuleListView'; import type { AutomodLogListViewProps } from '@/services/juxt-web/views/web/admin/automodLogListView'; -export const automodRuleType = ['keyword'] as const; -export type AutomodRuleType = (typeof automodRuleType)[number]; - -export const automodRuleMode = ['block', 'log'] as const; -export type AutomodRuleMode = (typeof automodRuleMode)[number]; +const automodRuleType = ['keyword'] as const; +const automodRuleMode = ['block', 'log'] as const; export const adminAutomodRouter = express.Router(); From 9011348b5b69c941098bc5feeeff41dbfa5c79f9 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 21:22:42 +0200 Subject: [PATCH 073/103] fix: more explicit truthyness check --- .../src/services/juxt-web/views/portal/post.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx index b50a5582..7ce6411c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx @@ -52,7 +52,7 @@ export function PortalPostView(props: PostViewProps): ReactNode {
    Date: Thu, 2 Jul 2026 21:36:53 +0200 Subject: [PATCH 074/103] chore: improve clarity on community type checks --- .../src/services/juxt-web/routes/permissions.ts | 2 +- apps/miiverse-api/src/services/internal/utils/communities.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts index 037ee9f7..73ca9aa5 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts @@ -3,7 +3,7 @@ import type { Community, CommunityShotMode, Post, Self } from '@/api/generated'; export function isPostingAllowed(community: Community, user: Self, parentPost: Post | null): boolean { const isReply = !!parentPost; - const isPublicPostableCommunity = community.type >= 0 && community.type < 2; + const isPublicPostableCommunity = community.type === 0 || community.type === 1; const isOpenCommunity = community.permissions.open; const isCommunityAdmin = community.adminPids.includes(user.pid); diff --git a/apps/miiverse-api/src/services/internal/utils/communities.ts b/apps/miiverse-api/src/services/internal/utils/communities.ts index 7299551f..c4cbae8c 100644 --- a/apps/miiverse-api/src/services/internal/utils/communities.ts +++ b/apps/miiverse-api/src/services/internal/utils/communities.ts @@ -32,7 +32,7 @@ export const communityPlatformDisplayMap: Record = { export function isPostingAllowed(community: HydratedCommunityDocument, user: SelfDto, parentPost: HydratedPostDocument | null): boolean { const isReply = !!parentPost; - const isPublicPostableCommunity = community.type >= 0 && community.type < 2; + const isPublicPostableCommunity = community.type === 0 || community.type === 1; const isOpenCommunity = community.permissions.open; const isCommunityAdmin = community.admins?.includes(user.pid) ?? false; From 060c178543c12e1b6b40a8962c6b2349f9b96dd4 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 21:48:41 +0200 Subject: [PATCH 075/103] fix: remove useCache from newPostView --- .../src/services/juxt-web/views/web/newPostView.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx index 8a9f8f26..e8af286a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/newPostView.tsx @@ -1,7 +1,6 @@ import { T } from '@/services/juxt-web/views/common/components/T'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import type { ReactNode } from 'react'; import type { Community, CommunityShotMode } from '@/api/generated'; @@ -107,9 +106,7 @@ export function WebNewPostView(props: NewPostViewProps): ReactNode { } export function WebNewPostPage(props: NewPostViewProps): ReactNode { - const cache = useCache(); const user = useUser(); - const name = props.name ?? cache.getUserName(props.pid ?? 0); let content = ; if (!user.perms.moderator) { @@ -123,7 +120,7 @@ export function WebNewPostPage(props: NewPostViewProps): ReactNode { return (

    - +

    {content} From d25c06d01f9dbb23a619db88cf60524ff135a7fb Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 21:51:13 +0200 Subject: [PATCH 076/103] fix: use new isSpoiler property --- .../juxtaposition-ui/src/services/juxt-web/views/web/post.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index 0980fcd5..52b8f392 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -150,7 +150,7 @@ export function WebPostView(props: PostViewProps): ReactNode { {' '} - { isModerator && post.is_spoiler + { isModerator && post.isSpoiler ? (
  • @@ -159,7 +159,7 @@ export function WebPostView(props: PostViewProps): ReactNode {
  • ) : null} - { isModerator && !post.is_spoiler + { isModerator && !post.isSpoiler ? (
  • From 6399c0483038f02c983a3d20c6975137fa721d9f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 2 Jul 2026 22:23:41 +0200 Subject: [PATCH 077/103] fix: prevent usage of unstable community IDs + fix type generation errors for communities --- .../src/services/juxt-web/routes/console/posts.tsx | 10 +++++----- .../src/services/juxt-web/views/ctr/post.tsx | 6 +++--- .../juxt-web/views/ctr/userPageFollowingView.tsx | 6 +++--- .../src/services/juxt-web/views/portal/post.tsx | 6 +++--- .../juxt-web/views/portal/userPageFollowingView.tsx | 6 +++--- .../juxt-web/views/web/admin/manageCommunityView.tsx | 4 ++-- .../src/services/juxt-web/views/web/post.tsx | 2 +- .../juxt-web/views/web/userPageFollowingView.tsx | 2 +- .../src/services/internal/contract/community.ts | 8 ++++---- .../src/services/internal/contract/post.ts | 4 ++-- 10 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 5dd47f49..1367ec86 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -67,7 +67,7 @@ postsRouter.get('/:post_id/oembed.json', async function (req, res) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: post.community.id }); + const { data: community } = await req.api.communities.get({ id: post.community?.olive_community_id ?? '' }); let img = {}; if (post.painting) { @@ -155,7 +155,7 @@ postsRouter.get('/:post_id', async function (req, res) { if (!post.community) { return res.redirect('/404'); } - const { data: community } = await req.api.communities.get({ id: post.community.id }); + const { data: community } = await req.api.communities.get({ id: post.community.olive_community_id }); if (!community) { return res.redirect('/404'); } @@ -232,7 +232,7 @@ postsRouter.get('/:post_id/create', async function (req, res) { if (!parent.community) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: parent.community.id }); + const { data: community } = await req.api.communities.get({ id: parent.community.olive_community_id }); if (!community) { return res.sendStatus(404); } @@ -240,7 +240,7 @@ postsRouter.get('/:post_id/create', async function (req, res) { const shotMode = getShotMode(community, auth().paramPackData); const props: NewPostViewProps = { - id: parent.community.id, + id: parent.community.olive_community_id, name: parent.author.miiName, url: `/posts/${parent.id}/new`, show: 'post', @@ -425,5 +425,5 @@ async function newPost(req: Request, res: Response): Promise { return; } - res.redirect('/titles/' + newPostResult.community?.id + '/new'); + res.redirect('/titles/' + newPostResult.community?.olive_community_id + '/new'); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx index 9858f48e..73f5b7bd 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx @@ -85,11 +85,11 @@ export function CtrPostView(props: PostViewProps): ReactNode { { !props.isReply ? ( - + - + - {post.community.name} + {post.community?.name} ) : null} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx index 53f2f8d0..5a098015 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx @@ -19,9 +19,9 @@ export function CtrUserPageFollowingView(props: UserPageFollowingViewProps): Rea
  • ))} {props.communities.map(community => ( -
  • - - +
  • + +
    {community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx index 7ce6411c..487f2243 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx @@ -83,11 +83,11 @@ export function PortalPostView(props: PostViewProps): ReactNode { { !props.isReply ? ( - + - + - {post.community.name} + {post.community?.name} ) : null} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx index 6e612e53..7dc1fddb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx @@ -19,9 +19,9 @@ export function PortalUserPageFollowingView(props: UserPageFollowingViewProps):
  • ))} {props.communities.map(community => ( -
  • - - +
  • + +
    {community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx index 3de542f4..f82ec25e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx @@ -40,7 +40,7 @@ export function WebManageCommunityView(props: ManageCommunityViewProps): ReactNo <> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index 52b8f392..bfed562e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -71,7 +71,7 @@ export function WebPostView(props: PostViewProps): ReactNode {

    {moment(post.createdAt).fromNow()} {' - '} - {post.community ? {post.community.name} : null } + {post.community ? {post.community.name} : null }

    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx index 59b46c10..d8666aa5 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx @@ -25,7 +25,7 @@ export function WebUserPageFollowingView(props: UserPageFollowingViewProps): Rea
  • ))} {props.communities.map(community => ( -
  • +
  • diff --git a/apps/miiverse-api/src/services/internal/contract/community.ts b/apps/miiverse-api/src/services/internal/contract/community.ts index f017dee6..84b2be37 100644 --- a/apps/miiverse-api/src/services/internal/contract/community.ts +++ b/apps/miiverse-api/src/services/internal/contract/community.ts @@ -29,7 +29,7 @@ export const communityIconsSchema = z.object({ }).openapi('CommunityIcons'); export const communitySchema = asOpenapi('Community', z.object({ - id: z.string(), + unstable_id: z.string(), olive_community_id: z.string(), parentId: z.string().nullable(), permissions: communityPermissionSchema, @@ -51,7 +51,7 @@ export const communitySchema = asOpenapi('Community', z.object({ export type CommunityDto = z.infer; export const shallowCommunitySchema = asOpenapi('ShallowCommunity', z.object({ - id: z.string(), + unstable_id: z.string(), olive_community_id: z.string(), name: z.string(), type: z.number(), @@ -92,7 +92,7 @@ export function mapCommunity(comm: HydratedCommunityDocument): CommunityDto { const imageId = comm.parent ? comm.parent : comm.olive_community_id; const shallowCommunity = mapShallowCommunity(comm); return { - id: shallowCommunity.id, + unstable_id: shallowCommunity.unstable_id, olive_community_id: shallowCommunity.olive_community_id, type: shallowCommunity.type, name: shallowCommunity.name, @@ -116,7 +116,7 @@ export function mapCommunity(comm: HydratedCommunityDocument): CommunityDto { export function mapShallowCommunity(comm: HydratedCommunityDocument): ShallowCommunityDto { const imageId = comm.parent ? comm.parent : comm.olive_community_id; return { - id: comm.community_id, + unstable_id: comm.community_id, olive_community_id: comm.olive_community_id, type: comm.type, name: comm.name, diff --git a/apps/miiverse-api/src/services/internal/contract/post.ts b/apps/miiverse-api/src/services/internal/contract/post.ts index 5b0097af..e209aac7 100644 --- a/apps/miiverse-api/src/services/internal/contract/post.ts +++ b/apps/miiverse-api/src/services/internal/contract/post.ts @@ -11,7 +11,7 @@ export const postSchema = asOpenapi('Post', z.object({ createdAt: z.date(), parentId: z.string().nullable(), dmTo: z.number().nullable(), - community: shallowCommunitySchema.nullable(), + community: shallowCommunitySchema.optional(), author: z.object({ miiName: z.string(), pid: z.number(), @@ -77,7 +77,7 @@ export function mapPost(post: IPost, comm: HydratedCommunityDocument | null): Po createdAt: post.created_at, parentId: post.parent ?? null, dmTo: post.message_to_pid ? Number(post.message_to_pid) : null, - community: comm ? mapShallowCommunity(comm) : null, + community: comm ? mapShallowCommunity(comm) : undefined, author: { miiName: post.screen_name, pid: post.pid, From 54a25836c08c89850a609340e24bbc5243c64e4a Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 3 Jul 2026 20:33:49 +0200 Subject: [PATCH 078/103] fix: fixed duplicate user in follower notification --- .../src/services/internal/contract/notification.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 7043b798..f51a79ee 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -30,17 +30,9 @@ export function mapNotification(notif: INotification, users: HydratedSettingsDoc const toUser = users.find(u => u.pid === Number(notif.pid)); const type = notif.type as NotificationType; - const followUsers: NotificationDto['users'] = []; + let followUsers: NotificationDto['users'] = []; if (type === 'follow') { - const pid = Number(notif.objectID); - const user = users.find(u => u.pid === pid); - followUsers.push({ - pid, - timestamp: notif.lastUpdated, // Not actually correct, but whatever - user: user ? mapShallowUser(user) : null - }); - - const restUsers = notif.users.map((v) => { + followUsers = notif.users.map((v) => { const pid = Number(v.user); const user = users.find(u => u.pid === pid); return { @@ -49,7 +41,6 @@ export function mapNotification(notif: INotification, users: HydratedSettingsDoc user: user ? mapShallowUser(user) : null }; }); - followUsers.push(...restUsers); } return { From d3b8c2553b4ca0de72c0d4262967c43473229389 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 3 Jul 2026 21:02:51 +0200 Subject: [PATCH 079/103] feat: restyle reply-age-sorter --- apps/juxtaposition-ui/src/assets/locales/en.json | 5 +++-- .../src/services/juxt-web/views/web/assets/up_down.svg | 7 +++++++ .../juxt-web/views/web/components/ui/WebUIIcon.tsx | 4 +++- .../src/services/juxt-web/views/web/postPageView.tsx | 6 ++++-- apps/juxtaposition-ui/webfiles/web/css/web.scss | 10 +++++++++- 5 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index da5337e6..e5215bbc 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -128,8 +128,9 @@ "delete_post": "Delete post", "removed": "Post has been removed.", "copy_link": "Copy link", - "sort_newest_first": "Newest posts", - "sort_oldest_first": "Oldest posts" + "sort_newest_first": "Sort by newest", + "sort_oldest_first": "Sort by oldest", + "replies_heading": "Replies" }, "setup": { "title": "Juxtaposition Setup", diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg new file mode 100644 index 00000000..721e2f91 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebUIIcon.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebUIIcon.tsx index b826cfaa..f0b85f00 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebUIIcon.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebUIIcon.tsx @@ -19,6 +19,7 @@ import hammerIcon from '../../assets/hammer.svg?raw'; import rightIcon from '../../assets/right_line.svg?raw'; import eyeIcon from '../../assets/eye.svg?raw'; import eyeSlashIcon from '../../assets/eye_slash.svg?raw'; +import upDownIcon from '../../assets/up_down.svg?raw'; import type { ReactNode } from 'react'; const icons = { @@ -39,7 +40,8 @@ const icons = { 'hammer': hammerIcon, 'right-arrow': rightIcon, 'eye': eyeIcon, - 'eye-slash': eyeSlashIcon + 'eye-slash': eyeSlashIcon, + 'up-down': upDownIcon } as const; type WebUIIcon = keyof typeof icons; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx index 683c156e..65a3815e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx @@ -81,11 +81,11 @@ export function WebPostPageView(props: PostPageViewProps): ReactNode {
    -

    Replies

    +

    {user.perms.moderator ? (
    - + ) @@ -93,11 +93,13 @@ export function WebPostPageView(props: PostPageViewProps): ReactNode { {props.sort === 'newest-first' ? ( + ) : ( + )} diff --git a/apps/juxtaposition-ui/webfiles/web/css/web.scss b/apps/juxtaposition-ui/webfiles/web/css/web.scss index c2e6af2e..4fd8dbec 100644 --- a/apps/juxtaposition-ui/webfiles/web/css/web.scss +++ b/apps/juxtaposition-ui/webfiles/web/css/web.scss @@ -1905,8 +1905,16 @@ button.report { } .reply-icon { - transform: scale(0.8); color: var(--text-light); + font-size: 1.3em; + line-height: 0.7; + margin-right: 0.2em; + margin-left: -0.2em; + + &.small { + font-size: 1em; + transform: scale(0.8); + } } } From f2fe65d78fba84ebf4ee981ee41429ec6ad6a986 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 3 Jul 2026 21:30:46 +0200 Subject: [PATCH 080/103] fix: fix duplicate users in migrated notifications --- migrations/8-notification-to-pg/script.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/migrations/8-notification-to-pg/script.ts b/migrations/8-notification-to-pg/script.ts index 0434953e..59ef6c51 100644 --- a/migrations/8-notification-to-pg/script.ts +++ b/migrations/8-notification-to-pg/script.ts @@ -28,10 +28,6 @@ function createMetaFromDoc(doc: Document): { type: string, content: Record Date: Fri, 3 Jul 2026 21:47:25 +0200 Subject: [PATCH 081/103] chore: extract date parsing into separate method --- migrations/8-notification-to-pg/script.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/migrations/8-notification-to-pg/script.ts b/migrations/8-notification-to-pg/script.ts index 59ef6c51..82f69636 100644 --- a/migrations/8-notification-to-pg/script.ts +++ b/migrations/8-notification-to-pg/script.ts @@ -22,6 +22,10 @@ const pg = new PgClient({ }); await pg.connect(); +function parseDateString(str: string): Date { + return new Date(str); +} + function createMetaFromDoc(doc: Document): { type: string, content: Record} | null { if (doc.type === 'follow') { const users = doc.users.map((v: any) => ({ @@ -113,15 +117,10 @@ function createMetaFromDoc(doc: Document): { type: string, content: Record Date: Sat, 4 Jul 2026 01:14:01 +0200 Subject: [PATCH 082/103] locales(update): Updated Russian locale --- .../src/assets/locales/ru.json | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index f64fa9e0..9bbee6ef 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -14,7 +14,15 @@ "close": "Закрыть", "save": "Сохранить", "exit": "Выход", - "next": "Следующий" + "next": "Следующий", + "my_feed": "Моя лента", + "global_feed": "Общая лента", + "global_feed_short": "Общая", + "people_feed": "Лента подписок", + "people_feed_short": "Подписки", + "friend_requests": "Запросы в друзья", + "updates": "Обновления", + "search": "Поиск..." }, "all_communities": { "text": "Все сообщества", @@ -26,7 +34,13 @@ "posts": "Публикации", "tags": "Ярлыки", "recent": "Недавние сообщения", - "popular": "Популярные сообщения" + "popular": "Популярные сообщения", + "followers_count": "Подписчики: {{count}}", + "tags_not_applicable": "Н/Д", + "related": "Связанные сообщества", + "related_short": "Связанные", + "related_to": "Сообщества, связанные с {{community}}", + "closed": "Это сообщество закрыто для новых публикаций." }, "user_page": { "country": "Страна", @@ -37,13 +51,27 @@ "following": "Подписан", "followers": "Подписчики", "follow_user": "Подписаться", - "following_user": "Подписан" + "following_user": "Подписан", + "game_experience_unknown": "Неизвестно", + "settings": "Настройки", + "friend_requests": "Запросы", + "deleted": "Удалённый пользователь", + "banned": "Заблокированный пользователь", + "not_found": "Пользователь не существует", + "tester_tag": "Тестировщик", + "supporter_tag": "Спонсор", + "moderator_tag": "Модератор", + "developer_tag": "Разработчик" }, "user_settings": { "profile_settings": "Настройки профиля", "show_country": "Показывать страну в профиле", "show_birthday": "Показывать дату рождения в профиле", - "show_game": "Показывать игровой опыт в профиле" + "show_game": "Показывать игровой опыт в профиле", + "show_profile": "Показывать профиль гостям", + "save_action": "Сохранить настройки", + "gdpr_download": "Скачать данные пользователя", + "gdpr_download_action": "Скачать" }, "activity_feed": { "empty": "Тут пусто. Попробуйте на кого-то подписаться!" @@ -95,5 +123,24 @@ "ready_text": "Сначала загляните в некоторые сообщества и посмотрите, о чём пишут люди со всего мира. Воспользуйтесь этой возможностью, чтобы познакомиться с Juxt. Возможно, по пути вы сделаете несколько новых открытий!", "done": "Веселитесь в Juxt!", "done_button": "Вперёд!" + }, + "login": { + "title": "Вход в Juxtaposition", + "heading": "Войти в Juxtaposition", + "sub_title": "Введите ниже данные своего аккаунта", + "username": "Имя", + "password": "Пароль", + "forgot_password": "Забыли пароль?", + "no_account": "Нет аккаунта?", + "login_action": "Войти", + "no_account_setup": "Создание аккаунта доступно только при наличии привязанной консоли Wii U или 3DS." + }, + "error": { + "title": "Ошибка {{code}}", + "heading": "Ошибка {{code}}: {{message}}", + "message": "Ой! Похоже, нам не удалось найти нужную страницу.Проверьте ссылку или повторите попытку позже", + "no_access": "У вас нет прав для доступа к этому приложению ({{code}})", + "message_web": "Посмотреть текущий статус сервера.Или вернуться на главную страницу", + "error_details": "ID запроса: {{id}}" } } From e271326e682ce3e657c71439cb300f697950b096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AF=D1=80=D0=BE=D1=81=D0=BB=D0=B0=D0=B2?= Date: Sat, 4 Jul 2026 01:15:04 +0200 Subject: [PATCH 083/103] locales(update): Updated Russian locale --- apps/juxtaposition-ui/src/assets/locales/ru.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index 9bbee6ef..d48eebab 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -77,7 +77,8 @@ "empty": "Тут пусто. Попробуйте на кого-то подписаться!" }, "notifications": { - "none": "Нет уведомлений." + "none": "Нет уведомлений.", + "new_follower/one": " подписался(-ась) на вас!" }, "new_post": { "post_to": "Написать {{user}}", From eb70b4c82ad6390a240cc6c0a687e8e3aa5dc157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AF=D1=80=D0=BE=D1=81=D0=BB=D0=B0=D0=B2?= Date: Sat, 4 Jul 2026 01:15:31 +0200 Subject: [PATCH 084/103] locales(update): Updated Russian locale --- apps/juxtaposition-ui/src/assets/locales/ru.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index d48eebab..95b0b7c8 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -78,7 +78,10 @@ }, "notifications": { "none": "Нет уведомлений.", - "new_follower/one": " подписался(-ась) на вас!" + "new_follower/one": " подписался(-ась) на вас!", + "new_follower/two": " и подписались на вас!", + "new_follower/three": ", и ещё {{count_other}} пользователь подписались на вас!", + "new_follower/multiple": ", и ещё {{count_other}} подп. подписались на вас!" }, "new_post": { "post_to": "Написать {{user}}", From 5f37569077032c07ac23f7813877336a9fd94b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AF=D1=80=D0=BE=D1=81=D0=BB=D0=B0=D0=B2?= Date: Sat, 4 Jul 2026 01:15:48 +0200 Subject: [PATCH 085/103] locales(update): Updated Russian locale --- apps/juxtaposition-ui/src/assets/locales/ru.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index 95b0b7c8..e29dd695 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -146,5 +146,8 @@ "no_access": "У вас нет прав для доступа к этому приложению ({{code}})", "message_web": "Посмотреть текущий статус сервера.Или вернуться на главную страницу", "error_details": "ID запроса: {{id}}" + }, + "friend_requests": { + "none": "Нет заявок в друзья" } } From 4e18087775a2b653cb101601b263df9398d17198 Mon Sep 17 00:00:00 2001 From: JM Date: Sat, 4 Jul 2026 15:41:33 +0200 Subject: [PATCH 086/103] locales(update): Updated Spanish locale --- apps/juxtaposition-ui/src/assets/locales/es.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/es.json b/apps/juxtaposition-ui/src/assets/locales/es.json index f84f6cee..1a2d08ce 100644 --- a/apps/juxtaposition-ui/src/assets/locales/es.json +++ b/apps/juxtaposition-ui/src/assets/locales/es.json @@ -39,7 +39,8 @@ "related": "Otras comunidades", "related_to": "Otras comunidades de {{community}}", "closed": "Esta comunidad está cerrada a nuevos mensajes.", - "tags_not_applicable": "N/A" + "tags_not_applicable": "N/A", + "related_short": "Relacionado" }, "user_page": { "country": "País", @@ -55,7 +56,9 @@ "game_experience_unknown": "Desconocido", "settings": "Ajustes", "deleted": "Usuario eliminado", - "banned": "Usuario restringido" + "banned": "Usuario restringido", + "not_found": "Este usuario no existe", + "tester_tag": "Tester" }, "user_settings": { "profile_settings": "Ajustes del perfil", From a35f554b1845369969dc5162ee24eb13861f188e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AF=D1=80=D0=BE=D1=81=D0=BB=D0=B0=D0=B2?= Date: Sat, 4 Jul 2026 01:41:50 +0200 Subject: [PATCH 087/103] locales(update): Updated Russian locale --- .../src/assets/locales/ru.json | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index e29dd695..18261cd9 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -85,7 +85,15 @@ }, "new_post": { "post_to": "Написать {{user}}", - "swearing": "Сообщение не должно иметь нецензурную лексику." + "swearing": "Сообщение не должно иметь нецензурную лексику.", + "automod_error": "Ваша публикация содержит текст, запрещённый в Juxtaposition. Для получения дополнительной информации, пожалуйста, посетите https://preten.do/juxt-rules", + "new_post_short": "Пост", + "content_placeholder": "Поделитесь своими мыслями в публикации для сообщества или своих подписчиков.", + "screenshots_coming_soon": "Скриншоты ещё не готовы. Загляните позже!", + "no_screenshot": "Без скриншота", + "spoiler_label": "Спойлер", + "painting_close": "Отмена", + "painting_submit": "ОК" }, "setup": { "welcome": "Добро пожаловать в Juxtaposition!", @@ -126,7 +134,8 @@ "ready": "Готовы начать использовать Juxt", "ready_text": "Сначала загляните в некоторые сообщества и посмотрите, о чём пишут люди со всего мира. Воспользуйтесь этой возможностью, чтобы познакомиться с Juxt. Возможно, по пути вы сделаете несколько новых открытий!", "done": "Веселитесь в Juxt!", - "done_button": "Вперёд!" + "done_button": "Вперёд!", + "title": "Настройка Juxtaposition" }, "login": { "title": "Вход в Juxtaposition", @@ -149,5 +158,44 @@ }, "friend_requests": { "none": "Нет заявок в друзья" + }, + "post": { + "title": "Публикация от {{username}}", + "heading": "Публикация", + "yeahs_count/one": " пользователь поставил отметку «Здорово».", + "yeahs_count/multiple": "Пользователей, которым это «Здорово!»: .", + "show_spoiler": "Показать спойлер", + "reply_post": "Ответить", + "report_post": "Пожаловаться", + "delete_post": "Удалить публикацию", + "removed": "Публикация была удалена.", + "copy_link": "Копировать ссылку", + "sort_newest_first": "Сначала новые", + "sort_oldest_first": "Сначала старые" + }, + "reporting": { + "reason_spoiler": "Спойлер", + "reason_personal_info": "Личные данные", + "reason_violence": "Жестокий контент", + "reason_inappropiate": "Недопустимое/вредоносное поведение", + "reason_bullying": "Оскорбления/травля", + "reason_advertising": "Реклама", + "reason_nsfw": "Материалы сексуального характера", + "reason_piracy": "Пиратство", + "reason_inappropiate_ingame": "Недопустимое поведение в игре", + "reason_missing_images": "Отсутствуют изображения", + "reason_others": "Другое", + "title": "Пожаловаться на публикацию", + "submit": "Отправить жалобу", + "description": "Вы собираетесь пожаловаться на публикацию, содержание которой нарушает Правила поведения в Juxtaposition. Эта жалоба будет отправлена администраторам Juxtaposition в Pretendo, а не автору публикации.", + "label": "Причина:", + "additional_info_placeholder": "Введите дополнительные комментарии или информацию»" + }, + "moderation": { + "moderate_user": "Применить меры к пользователю", + "mark_as_not_spoiler": "Удалить пометку спойлера", + "silently_delete_post": "Удалить незаметно", + "title": "Модерация", + "mark_as_spoiler": "Отметить как спойлер" } } From 8456044a97ea6b456caa70bc3ef60017b63425e0 Mon Sep 17 00:00:00 2001 From: Leon Culver Date: Sat, 4 Jul 2026 01:25:56 +0200 Subject: [PATCH 088/103] locales(update): Updated Russian locale --- apps/juxtaposition-ui/src/assets/locales/ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index 18261cd9..d9ef780e 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -7,7 +7,7 @@ "notifications": "Уведомления", "go_back": "Вернуться", "back": "Назад", - "yeahs": "Классно", + "yeahs": "Лайки", "more": "Загрузить больше сообщений", "no_posts": "Нет постов", "private": "Приватный", From 5f051aed503fbf3331c6200e4e9a8fac20d9f561 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:22:52 -0700 Subject: [PATCH 089/103] feat: Parse Date Strings into Date Object --- migrations/8-notification-to-pg/script.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/migrations/8-notification-to-pg/script.ts b/migrations/8-notification-to-pg/script.ts index 82f69636..16fcbb23 100644 --- a/migrations/8-notification-to-pg/script.ts +++ b/migrations/8-notification-to-pg/script.ts @@ -23,7 +23,11 @@ const pg = new PgClient({ await pg.connect(); function parseDateString(str: string): Date { - return new Date(str); + if (str == "null" || str == "Invalid date") { + return new Date(); + } else { + return new Date(str); + } } function createMetaFromDoc(doc: Document): { type: string, content: Record} | null { From 21c2cb1faaaf359a935849a2c00788b2d458ffdf Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 5 Jul 2026 23:44:13 +0200 Subject: [PATCH 090/103] fix: fixed misaligned topics icon --- .../src/services/juxt-web/views/ctr/post.tsx | 2 +- .../webfiles/ctr/css/juxt.scss | 62 +++++++++++-------- .../webfiles/portal/css/juxt.css | 4 +- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx index 73f5b7bd..2d88a20a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx @@ -73,7 +73,7 @@ export function CtrPostView(props: PostViewProps): ReactNode { <>
    - + {post.topicTag} diff --git a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss index 66ff9003..5a2fa1e8 100644 --- a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss +++ b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss @@ -13,23 +13,26 @@ body { margin: 0; - font-family: nintendo_NTLG-DB_001,sans-serif; + font-family: nintendo_NTLG-DB_001, sans-serif; width: 400px; background: #00f } #body, body { - overflow-x: clip; /* Doesn't work on CTR, but the CSS relies on it not working */ + overflow-x: clip; + /* Doesn't work on CTR, but the CSS relies on it not working */ } #body { background-image: url(@/images/background.png); background-repeat: repeat; - padding-bottom: 28px; /* Toolbar location */ + padding-bottom: 28px; + /* Toolbar location */ -webkit-box-sizing: border-box; - min-height: 460px; /* Never show the blue background! */ + min-height: 460px; + /* Never show the blue background! */ } a { @@ -64,12 +67,12 @@ ul { padding: 0 } -.headline > h2 { +.headline>h2 { font-size: 18px; padding: 0 10px } -#body > .body-content { +#body>.body-content { width: 322px; margin-left: 39px; min-height: 175px; @@ -95,13 +98,13 @@ menu.tab-header { border-color: #59c9a5 } -.icon-container > .icon { +.icon-container>.icon { width: 64px; height: 64px; background: #e6eaf1 } -.list-content-with-icon-column > li { +.list-content-with-icon-column>li { display: table; width: 300px; border-bottom: 3px dotted #d2b0e1; @@ -144,7 +147,8 @@ menu.tab-header { right: 10px; top: 2px; } -.text > span > span { + +.text>span>span { vertical-align: middle; } @@ -197,7 +201,7 @@ menu.tab-header.user-page li:first-child { } menu.tab-header.user-page li.double, -menu.tab-header.user-page li.double > a { +menu.tab-header.user-page li.double>a { width: 146px } @@ -264,25 +268,30 @@ menu.tab-header.user-page li:last-child { margin-top: 10px; } -.post header > a, -.tags { +.post header>a { display: inline-block; - position: absolute; } -.tags { - color: #a362d8; - font-size: 10px; - line-height: 12px; - margin-top: 1px; - margin-left: 0 +.tags-container { + >.sprite { + margin-right: 5px; + position: relative; + top: 3px; + } + + .tags { + display: inline-block; + color: #a362d8; + font-size: 10px; + line-height: 12px; + } } .post-content-text { padding: 5px; font-size: 15px; - word-wrap: break-word; - white-space: pre-wrap; + word-wrap: break-word; + white-space: pre-wrap; } .post header { @@ -341,7 +350,7 @@ menu.tab-header.user-page li:last-child { display: inline-block } -.spoiler-wrapper > button { +.spoiler-wrapper>button { position: relative; z-index: 2; font-size: 12px; @@ -454,23 +463,26 @@ menu.tab-header.no-margin { font-weight: 400; color: #969696; } + .body.message span { font-size: 10px; color: #646464; } + .body.message .nick-name { font-size: 14px; color: #000; } + .body .nick-name { color: #000; } -.settings-list > li { +.settings-list>li { display: inline-block; } p.settings-label { - float: left; - width: 275px; + float: left; + width: 275px; } \ No newline at end of file diff --git a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css index 541f88b6..62cfa68c 100644 --- a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css +++ b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css @@ -513,8 +513,8 @@ body { header svg { fill:#a362d8; -webkit-transform:translate(0px,10px) scale(0.8); - margin-left: 20px; - margin-right: 5px; + margin-left: 15px; + margin-right: 1px; } .tags, header svg { From e7e23a07e133d9861d958831b6ec74240beb9213 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Mon, 6 Jul 2026 00:09:40 +0200 Subject: [PATCH 091/103] feat: clean up notification rendering into separate components --- .../views/ctr/notificationListView.tsx | 284 +++++++++-------- .../views/portal/notificationListView.tsx | 286 +++++++++-------- .../views/web/notificationListView.tsx | 289 ++++++++++-------- 3 files changed, 459 insertions(+), 400 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index c1f4505b..c23c60aa 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -6,160 +6,178 @@ import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageTitledHeader } from '@/services/juxt-web/views/ctr/components/CtrPageHeader'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; -import type { ShallowUser } from '@/api/generated'; +import type { NotificationItemProps, NotificationItemTypeProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { FollowNotification, LimitedFromPostingNotification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; -function CtrNotificationItem(props: NotificationItemProps): ReactNode { - const data = props.notification; - if (data.notif.type === 'follow') { - const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; - const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - const latestUser = users[0]; +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; - if (users.length === 2) { - i18nKey = 'notifications.new_follower.message/two'; - } - if (users.length === 3) { - i18nKey = 'notifications.new_follower.message/three'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( + <> + +
    +

    + + , + follower_two: + }} + /> + + + {' '} + {humanFromNow(props.data.updatedAt)} + +

    +
    + + ); +} + +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } - if (users.length > 3) { - i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( + <> + + + + ); +} + +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; } + } - return ( - <> - -
    -

    - + return ( + <> + +

    + +

    + , - follower_two: + /> + {' '} + - + {' '} - {humanFromNow(data.updatedAt)} + {humanFromNow(props.data.updatedAt)}

    -
    - - ); + +
    + + ); +} + +function SystemNotificationView(props: NotificationItemTypeProps): ReactNode { + return ( + <> + + + + ); +} + +function CtrNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; } - if (data.notif.type === 'postDeleted') { - let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.post_removed_for_reason'; - } - if (data.notif.content.postType === 'comment') { - i18nKey = 'notifications.post_deleted.comment_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; - } - } - return ( - <> - - - - ); + if (notif.type === 'postDeleted') { + return ; } - if (data.notif.type === 'limitedFromPosting') { - let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.message_with_reason'; - } - if (data.notif.content.until) { - i18nKey = 'notifications.limited_from_posting.temporary'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; - } - } - return ( - <> - - - - ); + if (notif.type === 'limitedFromPosting') { + return ; } - if (data.notif.type === 'system') { - return ( - <> - - - - ); + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 5548afd3..96b8f638 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -6,156 +6,174 @@ import { PortalMiiIcon } from '@/services/juxt-web/views/portal/components/ui/Po import { PortalIcon } from '@/services/juxt-web/views/portal/components/ui/PortalIcon'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; -import type { ShallowUser } from '@/api/generated'; +import type { NotificationItemProps, NotificationItemTypeProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { FollowNotification, LimitedFromPostingNotification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; -function PortalNotificationItem(props: NotificationItemProps): ReactNode { - const data = props.notification; - if (data.notif.type === 'follow') { - const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; - const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - const latestUser = users[0]; +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; - if (users.length === 2) { - i18nKey = 'notifications.new_follower.message/two'; - } - if (users.length === 3) { - i18nKey = 'notifications.new_follower.message/three'; - } - if (users.length > 3) { - i18nKey = 'notifications.new_follower.message/multiple'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( + <> + +
    +

    + + , + follower_two: + }} + /> + + + {' '} + {humanFromNow(props.data.updatedAt)} + +

    +
    + + ); +} + +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } + } - return ( - <> - - + + ); +} - if (data.notif.type === 'postDeleted') { - let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.post_removed_for_reason'; - } - if (data.notif.content.postType === 'comment') { - i18nKey = 'notifications.post_deleted.comment_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; - } +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; } - return ( - <> - - + + ); +} - if (data.notif.type === 'limitedFromPosting') { - let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.message_with_reason'; - } - if (data.notif.content.until) { - i18nKey = 'notifications.limited_from_posting.temporary'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; - } - } - return ( - <> - - + + ); +} + +function PortalNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; } - if (data.notif.type === 'system') { - return ( - <> - - - - ); + if (notif.type === 'postDeleted') { + return ; + } + + if (notif.type === 'limitedFromPosting') { + return ; + } + + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 6092d09e..bccdde43 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -6,7 +6,7 @@ import { T } from '@/services/juxt-web/views/common/components/T'; import { humanDate, humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { Notification, ShallowUser } from '@/api/generated'; +import type { FollowNotification, LimitedFromPostingNotification, Notification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; export type NotificationWrapperViewProps = { children?: ReactNode; @@ -20,156 +20,179 @@ export type NotificationItemProps = { notification: Notification; }; -function WebNotificationItem(props: NotificationItemProps): ReactNode { +export type NotificationItemTypeProps = { + data: Notification; + notif: T; +}; + +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { const url = useUrl(); - const data = props.notification; - if (data.notif.type === 'follow') { - const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? 'Nobody'}; - const users = [...data.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - const latestUser = users[0]; - - let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; - if (users.length === 2) { - i18nKey = 'notifications.new_follower.message/two'; - } - if (users.length === 3) { - i18nKey = 'notifications.new_follower.message/three'; - } - if (users.length > 3) { - i18nKey = 'notifications.new_follower.message/multiple'; - } + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? 'Nobody'}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - return ( - - ); + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; } - if (data.notif.type === 'postDeleted') { - let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.post_removed_for_reason'; - } - if (data.notif.content.postType === 'comment') { - i18nKey = 'notifications.post_deleted.comment_removed'; - if (data.notif.content.reason) { - i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; - } - } - return ( - + ); +} - if (data.notif.type === 'limitedFromPosting') { - let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.message_with_reason'; - } - if (data.notif.content.until) { - i18nKey = 'notifications.limited_from_posting.temporary'; - if (data.notif.content.reason) { - i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; - } +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } - return ( - + ); +} + +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } } - if (data.notif.type === 'system') { - return ( - + ); +} + +function SystemNotificationView(props: NotificationItemTypeProps): ReactNode { + return ( + + ); +} + +function WebNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; + } + + if (notif.type === 'postDeleted') { + return ; + } + + if (notif.type === 'limitedFromPosting') { + return ; + } + + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; From 2301aff30323cf8cbc8b3a8177f1e890ba1a7c79 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Mon, 6 Jul 2026 20:30:25 +0200 Subject: [PATCH 092/103] fix: add exit() callback back --- .../src/services/juxt-web/views/portal/firstRunView.tsx | 2 +- apps/juxtaposition-ui/webfiles/portal/js/juxt.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx index a93a1f36..d7ce4199 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx @@ -85,7 +85,7 @@ export function PortalFirstRunView(_props: FirstRunViewProps): ReactNode {

    - + diff --git a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js index d23597a1..cd0f2566 100644 --- a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js +++ b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js @@ -6,7 +6,7 @@ import { initPostPageView } from './post'; import { initNavTabs } from './components/ui/PortalNavTabs'; import { initSearchForm } from './components/ui/PortalSearchForm'; import { initNavBar } from './components/PortalNavBar'; -import { back } from './nav'; +import { back, exit } from './nav'; export var pjax; setInterval(checkForUpdates, 30000); @@ -377,3 +377,8 @@ function input() { back(); } } + +function exitApplet() { + exit(); +} +window.exitApplet = exitApplet; From 2b8ebc638f2b0259fd803b755cc600e047918373 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:16:54 -0700 Subject: [PATCH 093/103] Add New Reasons to English Locale --- apps/juxtaposition-ui/src/assets/locales/en.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index 9ea19c93..63c7c8d7 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -206,7 +206,20 @@ "reason_piracy": "Piracy", "reason_inappropiate_ingame": "Inappropriate Behavior in Game", "reason_missing_images": "Missing Images", - "reason_others": "Other" + "reason_others": "Other", + "reason_not_nice": "Mean/Rude/Hateful (Rule 1)", + "reason_inappropriate": "Inappropriate/NSFW (Rule 2)", + "reason_spam": "Spam/Self-Promotion (Rule 3)", + "reason_offtopic": "Off-Topic (Rule 4)", + "reason_piracy_new": "Piracy (Rule 5)", + "reason_exploits": "API Abuse/Exploiting Bugs (Rule 8)", + "reason_drama": "Drama (Rule 9)", + "reason_cheating": "Cheating Online (Rule 10)", + "reason_spoiler_new": "Spoiler (Rule 11)", + "reason_personal_info_new": "Personal Information (Rule 12)", + "reason_politics": "Politics (Rule 13)", + "reason_misinformation": "Misinformation/Bad Advice (Rule 14)", + "reason_impersonation": "Impersonation (Rule 15)" }, "moderation": { "title": "Moderation", From a50f79c07b33f535ae69753ab1b6d9d467b35057 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:25:21 -0700 Subject: [PATCH 094/103] Update Report Options and Labels --- .../juxt-web/views/ctr/reportPostView.tsx | 25 +++++++++++-------- .../juxt-web/views/portal/reportPostView.tsx | 25 +++++++++++-------- .../juxt-web/views/web/reportModalView.tsx | 25 +++++++++++-------- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx index 7f851928..9e00cd83 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx @@ -22,17 +22,20 @@ export function CtrReportPostView(props: ReportPostViewProps): ReactNode { {' '}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx index e737cbe0..3524468a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx @@ -18,17 +18,20 @@ export function WebReportModalView(): ReactNode {

    From 8d4920b38200aa5d53f44cf3384d1568e48647b7 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:27:14 -0700 Subject: [PATCH 095/103] Update Reporting Description in English Locale --- apps/juxtaposition-ui/src/assets/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index 63c7c8d7..2249239d 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -193,7 +193,7 @@ "reporting": { "title": "Report Post", "submit": "Submit Report", - "description": "You are about to report a post with content which violates the Juxtaposition Code of Conduct. This report will be sent to Pretendo's Juxtaposition administrators and not to the creator of the post.", + "description": "You are about to report a post that violates the Juxtaposition Code of Conduct. This report will be sent only to Juxtaposition Moderators and not to the creator of the post.", "label": "Reason:", "additional_info_placeholder": "Enter additional comments or information", "reason_spoiler": "Spoiler", From c89c5cc6914cd11375813dea4922d2a298bf68d2 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:38:39 -0700 Subject: [PATCH 096/103] fix: Report Reason Values Align with Util Array Index --- .../juxt-web/views/ctr/reportPostView.tsx | 28 +++++++++---------- .../juxt-web/views/portal/reportPostView.tsx | 28 +++++++++---------- .../juxt-web/views/web/reportModalView.tsx | 28 +++++++++---------- apps/juxtaposition-ui/src/util.ts | 15 +++++++++- 4 files changed, 56 insertions(+), 43 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx index 9e00cd83..b9731336 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx @@ -22,20 +22,20 @@ export function CtrReportPostView(props: ReportPostViewProps): ReactNode { {' '}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx index 3524468a..d165d04a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx @@ -18,20 +18,20 @@ export function WebReportModalView(): ReactNode {

    diff --git a/apps/juxtaposition-ui/src/util.ts b/apps/juxtaposition-ui/src/util.ts index 74c3d361..b73f9b23 100644 --- a/apps/juxtaposition-ui/src/util.ts +++ b/apps/juxtaposition-ui/src/util.ts @@ -141,7 +141,20 @@ export function getReasonMap(): string[] { 'Piracy', 'Inappropriate Behavior in Game', 'Other', - 'Missing Images; Reach out to Jemma with post link to fix' + 'Missing Images; Reach out to Jemma with post link to fix', + 'Mean/Rude/Hateful (Rule 1)', + 'Inappropriate/NSFW (Rule 2)', + 'Spam/Self-Promotion (Rule 3)', + 'Off-Topic (Rule 4)', + 'Piracy (Rule 5)', + 'API Abuse/Exploiting Bugs (Rule 8)', + 'Drama (Rule 9)', + 'Cheating Online (Rule 10)', + 'Spoiler (Rule 11)', + 'Personal Information (Rule 12)', + 'Politics (Rule 13)', + 'Misinformation/Bad Advice (Rule 14)', + 'Impersonation (Rule 15)' ]; } From 570a5c07503ab3e5fc0d148f71676547352e0716 Mon Sep 17 00:00:00 2001 From: Joshua8600 <105813016+Joshua8600@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:58:16 -0700 Subject: [PATCH 097/103] Fix "Other" Value Having Wrong Index --- .../src/services/juxt-web/views/ctr/reportPostView.tsx | 2 +- .../src/services/juxt-web/views/portal/reportPostView.tsx | 2 +- .../src/services/juxt-web/views/web/reportModalView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx index b9731336..afd441ea 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/reportPostView.tsx @@ -35,7 +35,7 @@ export function CtrReportPostView(props: ReportPostViewProps): ReactNode { - +
  • diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx index d165d04a..891c50b3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx @@ -31,7 +31,7 @@ export function WebReportModalView(): ReactNode { - +
    From 7b12b46cb47226a885038495e0424d2ff20dcc39 Mon Sep 17 00:00:00 2001 From: DevDrew <87445064+DevDrew64@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:40:07 -0700 Subject: [PATCH 098/103] Added Succesful Report Toast Created a dedicated typescript for toasts, added a toast that appears following a successful report that negates the need for a client redirect. --- .../juxt-web/routes/console/posts.tsx | 2 +- .../webfiles/web/js/reports.js | 25 ++++++++++++++- .../juxtaposition-ui/webfiles/web/js/toast.ts | 31 +++++++++++++++++++ apps/juxtaposition-ui/webfiles/web/js/web.js | 22 +------------ 4 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 apps/juxtaposition-ui/webfiles/web/js/toast.ts diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 1367ec86..99e228da 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -296,7 +296,7 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { reasonId: body.reason }); - return res.redirect(`/posts/${post.id}`); + res.sendStatus(200); }); postsRouter.post('/:post_id/edit/spoiler', async function (req, res) { diff --git a/apps/juxtaposition-ui/webfiles/web/js/reports.js b/apps/juxtaposition-ui/webfiles/web/js/reports.js index e182ad4e..145d0b1a 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/reports.js +++ b/apps/juxtaposition-ui/webfiles/web/js/reports.js @@ -1,3 +1,6 @@ +import { Toast } from './toast'; +import { POST } from './xhr'; + export function initReportForm() { const modal = document.getElementById('report-form-modal'); if (!modal || modal.setupDone) { @@ -9,6 +12,26 @@ export function initReportForm() { modal.hidden = true; }); + const form = modal.querySelector('form'); + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + + const formData = new FormData(form); + const params = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + params.append(key, value); + } + + POST(form.action, params.toString(), (request) => { + if (request.status !== 200) { + Toast('Unable to submit report. Please try again later.'); + return; + } + Toast('Report submitted.'); + modal.hidden = true; + }); + }); + modal.setupDone = true; } @@ -24,4 +47,4 @@ export function reportPost(id) { formID.value = id; modal.hidden = false; -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/webfiles/web/js/toast.ts b/apps/juxtaposition-ui/webfiles/web/js/toast.ts new file mode 100644 index 00000000..1c6a0d9f --- /dev/null +++ b/apps/juxtaposition-ui/webfiles/web/js/toast.ts @@ -0,0 +1,31 @@ +export function Toast(text: string, ms?: number): void { + const toastElement = document.getElementById('toast'); + if (!toastElement) { + return; + } + toastElement.innerText = text; + toastElement.className = 'show'; + startHideToast(ms ?? 3000); +} + +export function initToast(): void { + const toastElement = document.getElementById('toast'); + if (!toastElement) { + return; + } + const attr = toastElement.getAttribute('data-show'); + if (!attr) { + return; + } + toastElement.removeAttribute('data-show'); + setTimeout(() => Toast(toastElement.innerText, 20000), 100); +} + +function startHideToast(ms: number): void { + setTimeout(function () { + const toastElement = document.getElementById('toast'); + if (toastElement) { + toastElement.className = toastElement.className.replace('show', ''); + } + }, ms); +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/webfiles/web/js/web.js b/apps/juxtaposition-ui/webfiles/web/js/web.js index a34b1346..49082aa4 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/web.js +++ b/apps/juxtaposition-ui/webfiles/web/js/web.js @@ -4,6 +4,7 @@ import { POST, GET } from './xhr'; import { deletePostById, spoilerPostById, unspoilerPostById } from './api'; import { initYeahButton } from './post'; import { initSearchForm } from './components/ui/WebSearchForm'; +import { Toast, initToast } from './toast'; setInterval(checkForUpdates, 30000); @@ -271,27 +272,6 @@ function copyToClipboard(text) { Toast('Copied to clipboard.'); } -function Toast(text, ms) { - const x = document.getElementById('toast'); - x.innerText = text; - x.className = 'show'; - startHideToast(ms ? ms : 3000); -} - -function initToast() { - const x = document.getElementById('toast'); - if (!x) { - return; // No toast on screen - } - const attr = x.getAttribute('data-show'); - if (!attr) { - return; // Nothing to do - } - - x.removeAttribute('data-show'); - setTimeout(() => Toast(x.innerText, 20000), 100); // Show after a delay so it shows an animation -} - function startHideToast(ms) { setTimeout(function () { const x = document.getElementById('toast'); From 79ea841c5ce1a1412b04f2f17741836e247cf6af Mon Sep 17 00:00:00 2001 From: DevDrew <87445064+DevDrew64@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:41:48 -0700 Subject: [PATCH 099/103] Fixed Lint Job Fail Added NewLine at EOF in toast.js and reports.js, removed redundant function declaration in web.js --- apps/juxtaposition-ui/webfiles/web/js/reports.js | 2 +- apps/juxtaposition-ui/webfiles/web/js/toast.ts | 2 +- apps/juxtaposition-ui/webfiles/web/js/web.js | 7 ------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/juxtaposition-ui/webfiles/web/js/reports.js b/apps/juxtaposition-ui/webfiles/web/js/reports.js index 145d0b1a..de569263 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/reports.js +++ b/apps/juxtaposition-ui/webfiles/web/js/reports.js @@ -47,4 +47,4 @@ export function reportPost(id) { formID.value = id; modal.hidden = false; -} \ No newline at end of file +} diff --git a/apps/juxtaposition-ui/webfiles/web/js/toast.ts b/apps/juxtaposition-ui/webfiles/web/js/toast.ts index 1c6a0d9f..6caa9681 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/toast.ts +++ b/apps/juxtaposition-ui/webfiles/web/js/toast.ts @@ -28,4 +28,4 @@ function startHideToast(ms: number): void { toastElement.className = toastElement.className.replace('show', ''); } }, ms); -} \ No newline at end of file +} diff --git a/apps/juxtaposition-ui/webfiles/web/js/web.js b/apps/juxtaposition-ui/webfiles/web/js/web.js index 49082aa4..ba79bdd4 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/web.js +++ b/apps/juxtaposition-ui/webfiles/web/js/web.js @@ -272,13 +272,6 @@ function copyToClipboard(text) { Toast('Copied to clipboard.'); } -function startHideToast(ms) { - setTimeout(function () { - const x = document.getElementById('toast'); - x.className = x.className.replace('show', ''); - }, ms); -} - function downloadURI(uri, name) { const link = document.createElement('a'); link.download = name; From 236432627beed43f0e5b6627c58b3b66e850339b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 7 Jul 2026 20:37:56 +0200 Subject: [PATCH 100/103] fix: allow report reason to be empty --- apps/miiverse-api/src/models/report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/miiverse-api/src/models/report.ts b/apps/miiverse-api/src/models/report.ts index 67f69e4b..55ad0905 100644 --- a/apps/miiverse-api/src/models/report.ts +++ b/apps/miiverse-api/src/models/report.ts @@ -20,7 +20,7 @@ export const ReportSchema = new Schema({ }, message: { type: String, - required: true + default: '' }, created_at: { type: Date, From 0c828555063f2ac0f71e7e1d120085344fefe70e Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 7 Jul 2026 22:09:41 +0200 Subject: [PATCH 101/103] fix: fixed report button on reply not working --- apps/juxtaposition-ui/webfiles/portal/js/juxt.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js index cd0f2566..748fa19c 100644 --- a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js +++ b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js @@ -329,17 +329,7 @@ window.stopLoading = stopLoading; function reportPost(post) { var id = post.getAttribute('data-post'); - var button = document.getElementById('report-launcher'); - var form = document.getElementById('report-form'); - var formID = document.getElementById('report-post-id'); - if (!id || !button || !form || !formID) { - return; - } - - form.action = '/posts/' + id + '/report'; - formID.value = id; - console.log(id.replace(/(\d{3})(\d{4})(\d{3})(\d{4})(\d{3})(\d{4})/, '$1-$2-$3-$4-$5-$6')); - button.click(); + pjax.loadUrl('/posts/' + id + '/report'); } window.reportPost = reportPost; From fd9d234f7ffa423584312137daaeb6779437db86 Mon Sep 17 00:00:00 2001 From: DevDrew <87445064+DevDrew64@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:08:38 -0700 Subject: [PATCH 102/103] Fixed Wiiu/3DS Reporting As suggested, used API=true in the url to indicate usage of the webui rather than console version triggering the toast logic rather than a redirect, whilst still alowing redirects to be used for the console versions. Also removed redundant toast logic from login.js as suggested. --- .../juxt-web/routes/console/posts.tsx | 13 ++++++-- .../juxtaposition-ui/webfiles/web/js/login.js | 31 ++----------------- .../webfiles/web/js/reports.js | 3 +- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 99e228da..9ec15beb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -277,11 +277,14 @@ postsRouter.get('/:post_id/report', async function (req, res) { }); postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { - const { body } = parseReq(req, { + const { body, query } = parseReq(req, { body: z.object({ reason: z.coerce.number(), message: z.string().trim(), post_id: z.string() + }), + query: z.object({ + api: z.enum(['true', 'false']).default('false') }) }); @@ -296,7 +299,13 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { reasonId: body.reason }); - res.sendStatus(200); + if (query.api === 'true') { + return res.sendStatus(200); + } + else + { + return res.redirect(`/posts/${post.id}`); + } }); postsRouter.post('/:post_id/edit/spoiler', async function (req, res) { diff --git a/apps/juxtaposition-ui/webfiles/web/js/login.js b/apps/juxtaposition-ui/webfiles/web/js/login.js index b28b484c..4e0cce00 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/login.js +++ b/apps/juxtaposition-ui/webfiles/web/js/login.js @@ -1,32 +1,5 @@ -// Toast code duplicated at web.js -function Toast(text, ms) { - const x = document.getElementById('toast'); - x.innerText = text; - x.className = 'show'; - startHideToast(ms ? ms : 3000); -} - -function initToast() { - const x = document.getElementById('toast'); - if (!x) { - return; // No toast on screen - } - const attr = x.getAttribute('data-show'); - if (!attr) { - return; // Nothing to do - } - - x.removeAttribute('data-show'); - setTimeout(() => Toast(x.innerText, 20000), 100); // Show after a delay so it shows an animation -} - -function startHideToast(ms) { - setTimeout(function () { - const x = document.getElementById('toast'); - x.className = x.className.replace('show', ''); - }, ms); -} +import { initToast } from './toast'; document.addEventListener('DOMContentLoaded', () => { initToast(); -}); +}); \ No newline at end of file diff --git a/apps/juxtaposition-ui/webfiles/web/js/reports.js b/apps/juxtaposition-ui/webfiles/web/js/reports.js index de569263..378eefe6 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/reports.js +++ b/apps/juxtaposition-ui/webfiles/web/js/reports.js @@ -43,8 +43,9 @@ export function reportPost(id) { return; } - form.action = `/posts/${id}/report`; + form.action = `/posts/${id}/report?api=true`; formID.value = id; modal.hidden = false; + form.reset(); } From b08402693d1d09b4590581d7651cf3a766acfc34 Mon Sep 17 00:00:00 2001 From: DevDrew <87445064+DevDrew64@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:32:04 -0700 Subject: [PATCH 103/103] Fix Lint Issues Used autolint (thanks for the tip!) --- .../src/services/juxt-web/routes/console/posts.tsx | 6 ++---- apps/juxtaposition-ui/webfiles/web/js/login.js | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 9ec15beb..b6cf5aeb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -301,10 +301,8 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { if (query.api === 'true') { return res.sendStatus(200); - } - else - { - return res.redirect(`/posts/${post.id}`); + } else { + return res.redirect(`/posts/${post.id}`); } }); diff --git a/apps/juxtaposition-ui/webfiles/web/js/login.js b/apps/juxtaposition-ui/webfiles/web/js/login.js index 4e0cce00..c85f4454 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/login.js +++ b/apps/juxtaposition-ui/webfiles/web/js/login.js @@ -2,4 +2,4 @@ import { initToast } from './toast'; document.addEventListener('DOMContentLoaded', () => { initToast(); -}); \ No newline at end of file +});