From 7e2f320b4fadc18da2282909fa116a4d05b09455 Mon Sep 17 00:00:00 2001 From: Arno Kaimbacher Date: Wed, 24 Jun 2026 15:03:17 +0200 Subject: [PATCH] feat: implement activity logging for user actions and create activities table --- app/Controllers/Http/Auth/AuthController.ts | 153 +++++++++--------- .../Http/Submitter/DatasetController.ts | 37 ++++- app/controllers/activities_controller.ts | 43 +++++ app/models/activity.ts | 57 +++++++ app/models/dataset.ts | 56 +++++-- app/models/user.ts | 6 + app/services/activity_logger.ts | 27 ++++ .../1782294801884_create_activities_table.ts | 25 +++ resources/js/Dataset.ts | 11 +- resources/js/Pages/Dashboard.vue | 145 +++++++++-------- resources/js/Stores/main.ts | 15 +- start/routes/api.ts | 3 + 12 files changed, 419 insertions(+), 159 deletions(-) create mode 100644 app/controllers/activities_controller.ts create mode 100644 app/models/activity.ts create mode 100644 app/services/activity_logger.ts create mode 100644 database/migrations/1782294801884_create_activities_table.ts diff --git a/app/Controllers/Http/Auth/AuthController.ts b/app/Controllers/Http/Auth/AuthController.ts index 08b1c05..90a02be 100644 --- a/app/Controllers/Http/Auth/AuthController.ts +++ b/app/Controllers/Http/Auth/AuthController.ts @@ -1,100 +1,68 @@ import type { HttpContext } from '@adonisjs/core/http'; import User from '#models/user'; import BackupCode from '#models/backup_code'; -// import Hash from '@ioc:Adonis/Core/Hash'; -// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException'; import { authValidator } from '#validators/auth'; import hash from '@adonisjs/core/services/hash'; import db from '@adonisjs/lucid/services/db'; import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider'; -// import { Authenticator } from '@adonisjs/auth'; -// import { LoginState } from 'Contracts/enums'; -// import { StatusCodes } from 'http-status-codes'; +import ActivityLogger from '#services/activity_logger'; +import logger from '@adonisjs/core/services/logger'; -// interface MyHttpsContext extends HttpContext { -// auth: Authenticator -// } export default class AuthController { - // login function{ request, auth, response }:HttpContext public async login({ request, response, auth, session }: HttpContext) { - // console.log({ - // registerBody: request.body(), - // }); - // await request.validate(AuthValidator); await request.validateUsing(authValidator); - // const plainPassword = await request.input('password'); - // const email = await request.input('email'); - // grab uid and password values off request body const { email, password } = request.only(['email', 'password']); try { - - await db.connection().rawQuery('SELECT 1') - - - // // attempt to verify credential and login user - // await auth.use('web').attempt(email, plainPassword); + await db.connection().rawQuery('SELECT 1'); - // const user = await auth.use('web').verifyCredentials(email, password); const user = await User.verifyCredentials(email, password); if (user.isTwoFactorEnabled) { - // session.put("login.id", user.id); - // return view.render("pages/two-factor-challenge"); - + // Noch KEIN abgeschlossenes Login -> nicht loggen. session.flash('user_id', user.id); return response.redirect().back(); - - // let state = LoginState.STATE_VALIDATED; - // return response.status(StatusCodes.OK).json({ - // state: state, - // new_user_id: user.id, - // }); } await auth.use('web').login(user); - } catch (error) { + this.recordAuthEvent('auth.login', { user, ip: request.ip() }); + } catch (error: any) { + // DB nicht erreichbar -> kein fehlgeschlagener Login-Versuch, weiterwerfen if (error.code === 'ECONNREFUSED') { - throw error + throw error; } - // if login fails, return vague form message and redirect back + + // Echter Credential-Fehler -> als fehlgeschlagenen Versuch protokollieren + this.recordAuthEvent('auth.login_failed', { email, ip: request.ip() }); + session.flash('message', 'Your username, email, or password is incorrect'); return response.redirect().back(); } - // otherwise, redirect todashboard - response.redirect('/apps/dashboard'); + return response.redirect('/apps/dashboard'); } public async twoFactorChallenge({ request, session, auth, response }: HttpContext) { - const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']); + const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']); const user = await User.query().where('id', login_id).firstOrFail(); if (code) { const isValid = await TwoFactorAuthProvider.validate(user, code); if (isValid) { - // login user and redirect to dashboard await auth.use('web').login(user); - response.redirect('/apps/dashboard'); - } else { - session.flash('message', 'Your two-factor code is incorrect'); - return response.redirect().back(); + this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_totp' }); + return response.redirect('/apps/dashboard'); } - } else if (backup_code) { - const codes: BackupCode[] = await user.getBackupCodes(); - - // const verifiedBackupCodes = await Promise.all( - // codes.map(async (backupCode) => { - // let isVerified = await hash.verify(backupCode.code, backup_code); - // if (isVerified) { - // return backupCode; - // } - // }), - // ); - // const backupCodeToDelete = verifiedBackupCodes.find(Boolean); - let backupCodeToDelete = null; + session.flash('message', 'Your two-factor code is incorrect'); + return response.redirect().back(); + } + + if (backup_code) { + const codes: BackupCode[] = await user.getBackupCodes(); + + let backupCodeToDelete: BackupCode | null = null; for (const backupCode of codes) { const isVerified = await hash.verify(backupCode.code, backup_code); if (isVerified) { @@ -103,29 +71,68 @@ export default class AuthController { } } - if (backupCodeToDelete) { - if (backupCodeToDelete.used === false) { - backupCodeToDelete.used = true; - await backupCodeToDelete.save(); - console.log(`BackupCode with id ${backupCodeToDelete.id} has been marked as used.`); - await auth.use('web').login(user); - response.redirect('/apps/dashboard'); - } else { - session.flash('message', 'BackupCode already used'); - return response.redirect().back(); - } - } else { + if (!backupCodeToDelete) { session.flash('message', 'BackupCode not found'); return response.redirect().back(); - } + } + + if (backupCodeToDelete.used) { + session.flash('message', 'BackupCode already used'); + return response.redirect().back(); + } + + backupCodeToDelete.used = true; + await backupCodeToDelete.save(); + + await auth.use('web').login(user); + this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_backup_code' }); + + return response.redirect('/apps/dashboard'); } + + // Weder code noch backup_code übergeben + session.flash('message', 'No verification code provided'); + return response.redirect().back(); } - // logout function - public async logout({ auth, response }: HttpContext) { - // await auth.logout(); + public async logout({ auth, request, response }: HttpContext) { + // Session auflösen -> füllt auth.user, falls eingeloggt + await auth.use('web').check(); + const user = auth.use('web').user; + await auth.use('web').logout(); + + if (user) { + this.recordAuthEvent('auth.logout', { user, email: user.email, ip: request.ip() }); + } + return response.redirect('/app/login'); - // return response.status(200); + } + + /** + * Zentraler Audit-Logger für Auth-Events. + * Fire-and-forget: ein Fehler beim Schreiben darf Login/Logout nie blockieren. + */ + private recordAuthEvent( + type: 'auth.login' | 'auth.logout' | 'auth.login_failed', + opts: { user?: User; email?: string; ip: string; method?: string }, + ) { + const { user, email, ip, method } = opts; + + const description = + type === 'auth.login' + ? `${user!.firstName} ${user!.lastName} signed in` + : type === 'auth.logout' + ? `${user!.firstName} ${user!.lastName} signed out` + : `Failed login attempt for ${email ?? 'unknown'}`; + + void ActivityLogger.log({ + type, + description, + userId: user?.id ?? null, + subjectType: user ? 'User' : null, + subjectId: user?.id ?? null, + properties: { ip, ...(method ? { method } : {}) }, + }).catch((err) => logger.error({ err }, `failed to record ${type} activity`)); } } diff --git a/app/Controllers/Http/Submitter/DatasetController.ts b/app/Controllers/Http/Submitter/DatasetController.ts index 23789b9..a5b484c 100644 --- a/app/Controllers/Http/Submitter/DatasetController.ts +++ b/app/Controllers/Http/Submitter/DatasetController.ts @@ -40,6 +40,8 @@ import type { Multipart } from '@adonisjs/bodyparser'; import * as fs from 'fs'; import { parseBytesSize, getConfigFor, getTmpPath, formatBytes, errorMessage } from '#app/utils/utility-functions'; import validation from '#services/validation_service'; +import ActivityLogger from '#services/activity_logger'; +import logger from '@adonisjs/core/services/logger'; interface Dictionary { [index: string]: string; @@ -469,9 +471,7 @@ export default class DatasetController { console.error('Error cleaning up temporary file:', cleanupError); } }); - request.multipart.abort( - validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit') - ); + request.multipart.abort(validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit')); } }); @@ -513,10 +513,21 @@ export default class DatasetController { trx = await db.transaction(); const user = (await User.find(auth.user?.id)) as User; - await this.createDatasetAndAssociations(user, request, trx); + // await this.createDatasetAndAssociations(user, request, trx); + const { dataset, mainTitle } = await this.createDatasetAndAssociations(user, request, trx); await trx.commit(); console.log('Dataset and related models created successfully'); + + // NACH dem Commit: Dataset ist garantiert persistiert, keine Waisen-Gefahr. + // Fire-and-forget, damit ein Log-Fehler den bereits erfolgreichen Upload nicht kippt. + void ActivityLogger.log({ + type: 'dataset.uploaded', + description: `New publication uploaded: ${mainTitle ?? 'Untitled'}`, + userId: user.id, + subjectType: 'Dataset', + subjectId: dataset.id, + }).catch((err) => logger.error({ err }, 'failed to record dataset.uploaded activity')); } catch (error) { // Clean up temporary files if validation or later steps fail uploadedTmpFiles.forEach((tmpPath) => { @@ -564,6 +575,15 @@ export default class DatasetController { await this.savePersons(dataset, request.input('contributors', []), 'contributor', trx); //save main and additional titles + // const titles = request.input('titles', []); + // for (const titleData of titles) { + // const title = new Title(); + // title.value = titleData.value; + // title.language = titleData.language; + // title.type = titleData.type; + // await dataset.useTransaction(trx).related('titles').save(title); + // } + let mainTitle: string | null = null; const titles = request.input('titles', []); for (const titleData of titles) { const title = new Title(); @@ -571,6 +591,11 @@ export default class DatasetController { title.language = titleData.language; title.type = titleData.type; await dataset.useTransaction(trx).related('titles').save(title); + + if (titleData.type === 'Main') { + // <-- an eure Typ-Konvention anpassen + mainTitle = titleData.value; + } } // save descriptions @@ -664,6 +689,8 @@ export default class DatasetController { await dataset.useTransaction(trx).related('files').save(newFile); await newFile.createHashValues(trx); } + + return { dataset, mainTitle }; // <-- statt void } private generateRandomString(length: number): string { @@ -1095,7 +1122,7 @@ export default class DatasetController { } }); const error = new errors.E_VALIDATION_ERROR({ - 'files': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, + files: `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, }); request.multipart.abort(error); } diff --git a/app/controllers/activities_controller.ts b/app/controllers/activities_controller.ts new file mode 100644 index 0000000..6526108 --- /dev/null +++ b/app/controllers/activities_controller.ts @@ -0,0 +1,43 @@ +// app/controllers/activities_controller.ts +import type { HttpContext } from '@adonisjs/core/http'; +import Activity from '#models/activity'; + +export default class ActivitiesController { + async index({ response }: HttpContext) { + // const activities = await Activity.query() + // .preload('user', (q) => q.select('id', 'login')) + // .orderBy('created_at', 'desc') + // .limit(10); + + // return response.json( + // activities.map((a) => ({ + // id: a.id, + // type: a.type, + // description: a.description, + // user: a.user?.login ?? null, + // created_at: a.createdAt.toISO(), // relativeTime() expects ISO + // })), + // ); + try { + const activities = await Activity.query() + .preload('user', (q) => q.select('id', 'login')) + .orderBy('created_at', 'desc') + .limit(10); + + return response.json( + activities.map((a) => ({ + id: a.id, + type: a.type, + description: a.description, + user: a.user?.login ?? null, + created_at: a.createdAt.toISO(), // relativeTime() expects ISO + })), + ); + } catch (error) { + console.error('Error fetching activities:', error); + return response.status(500).json({ error: 'Internal Server Error' }); + } + } + + +} diff --git a/app/models/activity.ts b/app/models/activity.ts new file mode 100644 index 0000000..9d3403a --- /dev/null +++ b/app/models/activity.ts @@ -0,0 +1,57 @@ +// app/models/activity.ts +import { DateTime } from 'luxon'; +import { belongsTo, column } from '@adonisjs/lucid/orm'; +import BaseModel from './base_model.js'; +import type { BelongsTo } from '@adonisjs/lucid/types/relations'; +import User from '#models/user'; +import { SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'; + +export default class Activity extends BaseModel { + public static namingStrategy = new SnakeCaseNamingStrategy(); + public static primaryKey = 'id'; + public static table = 'activities'; + + @column({ isPrimary: true }) + declare id: number; + + @column() + declare type: string; + + @column() + declare userId: number | null; + + @column() + declare subjectType: string | null; + + @column() + declare subjectId: number | null; + + @column() + declare description: string; + + // Manual JSON (de)serialization keeps this working on SQLite/MySQL. + // On Postgres json/jsonb the driver already parses — drop the `consume` + // JSON.parse there to avoid double-handling. + // @column({ + // prepare: (value: Record | null) => (value ? JSON.stringify(value) : null), + // consume: (value: string | null) => (value ? JSON.parse(value) : null), + // }) + // declare properties: Record | null; + + @column() +declare properties: Record | null; + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime; + + // @belongsTo(() => User) + // declare user: BelongsTo; + + @belongsTo(() => User, { + foreignKey: 'userId', + }) + declare user: BelongsTo; +} diff --git a/app/models/dataset.ts b/app/models/dataset.ts index 1233a28..1bf772b 100644 --- a/app/models/dataset.ts +++ b/app/models/dataset.ts @@ -5,7 +5,10 @@ import { belongsTo, hasMany, computed, - hasOne + hasOne, + afterCreate, + beforeUpdate, + afterUpdate, } from '@adonisjs/lucid/orm'; import { DateTime } from 'luxon'; import dayjs from 'dayjs'; @@ -23,10 +26,11 @@ import DatasetIdentifier from './dataset_identifier.js'; import Project from './project.js'; import DocumentXmlCache from './DocumentXmlCache.js'; import DatasetExtension from '#models/traits/dataset_extension'; -import type { ManyToMany } from "@adonisjs/lucid/types/relations"; -import type { BelongsTo } from "@adonisjs/lucid/types/relations"; -import type { HasMany } from "@adonisjs/lucid/types/relations"; -import type { HasOne } from "@adonisjs/lucid/types/relations"; +import type { ManyToMany } from '@adonisjs/lucid/types/relations'; +import type { BelongsTo } from '@adonisjs/lucid/types/relations'; +import type { HasMany } from '@adonisjs/lucid/types/relations'; +import type { HasOne } from '@adonisjs/lucid/types/relations'; +import ActivityLogger from '#services/activity_logger'; export default class Dataset extends DatasetExtension { public static namingStrategy = new SnakeCaseNamingStrategy(); @@ -46,7 +50,7 @@ export default class Dataset extends DatasetExtension { @column({ columnName: 'creating_corporation' }) public creating_corporation: string; - @column.dateTime({ + @column.dateTime({ columnName: 'embargo_date', serialize: (value: Date | null) => { return value ? dayjs(value).format('YYYY-MM-DD') : value; @@ -60,7 +64,7 @@ export default class Dataset extends DatasetExtension { @column({}) public language: string; - @column({columnName: 'publish_id'}) + @column({ columnName: 'publish_id' }) public publish_id: number | null = null; @column({}) @@ -266,10 +270,12 @@ export default class Dataset extends DatasetExtension { return model || null; } - static async getMax (column: string) { - let dataset = await this.query().max(column + ' as max_publish_id').firstOrFail(); + static async getMax(column: string) { + let dataset = await this.query() + .max(column + ' as max_publish_id') + .firstOrFail(); return dataset.$extras.max_publish_id; - } + } @computed({ serializeAs: 'remaining_time', @@ -284,4 +290,34 @@ export default class Dataset extends DatasetExtension { return 0; } } + + // @afterCreate() + // static async logUploaded(dataset: Dataset) { + // await dataset.preload('titles'); + + // await ActivityLogger.log({ + // type: 'dataset.uploaded', + // description: `New publication uploaded: ${dataset.mainTitle ?? 'Untitled'}`, + // subjectType: 'Dataset', + // subjectId: dataset.id, + // }); + // } + + @beforeUpdate() + static capturePublish(dataset: Dataset) { + // $dirty is populated here, before persistence + (dataset as any).$becamePublished = dataset.$dirty.status !== undefined && dataset.status === 'published'; + } + + @afterUpdate() + static async logPublished(dataset: Dataset) { + if ((dataset as any).$becamePublished) { + await ActivityLogger.log({ + type: 'dataset.published', + description: `Publication published: ${dataset.mainTitle}`, + subjectType: 'Dataset', + subjectId: dataset.id, + }); + } + } } diff --git a/app/models/user.ts b/app/models/user.ts index 7be02b9..038b569 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -14,6 +14,7 @@ import type { ManyToMany } from '@adonisjs/lucid/types/relations'; import type { HasMany } from '@adonisjs/lucid/types/relations'; import { compose } from '@adonisjs/core/helpers'; import BackupCode from './backup_code.js'; +import Activity from './activity.js'; const AuthFinder = withAuthFinder(() => hash.use('laravel'), { uids: ['email'], @@ -111,6 +112,11 @@ export default class User extends compose(BaseModel, AuthFinder) { }) public backupcodes: HasMany; + @hasMany(() => Activity, { + foreignKey: 'user_id', + }) + public activities: HasMany; + @computed({ serializeAs: 'is_admin', }) diff --git a/app/services/activity_logger.ts b/app/services/activity_logger.ts new file mode 100644 index 0000000..c81b4e5 --- /dev/null +++ b/app/services/activity_logger.ts @@ -0,0 +1,27 @@ +// app/services/activity_logger.ts +import Activity from '#models/activity' + +interface LogOptions { + type: string + description: string + userId?: number | null + subjectType?: string | null + subjectId?: number | string | null + properties?: Record +} + +export default class ActivityLogger { + static async log(options: LogOptions): Promise { + await Activity.create({ + type: options.type, + description: options.description, + userId: options.userId ?? null, + subjectType: options.subjectType ?? null, + subjectId: options.subjectId != null ? Number(options.subjectId) : null, + properties: options.properties ?? null, + }) + + // Invalidate the cache if you add one (see Redis section). + // await redis.del('activities:recent') + } +} \ No newline at end of file diff --git a/database/migrations/1782294801884_create_activities_table.ts b/database/migrations/1782294801884_create_activities_table.ts new file mode 100644 index 0000000..7b497be --- /dev/null +++ b/database/migrations/1782294801884_create_activities_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema'; + +export default class extends BaseSchema { + protected tableName = 'activities'; + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id'); + table.string('type').notNullable().index(); // 'dataset.uploaded', 'auth.login' + table.integer('user_id').unsigned().nullable().references('id').inTable('accounts').onDelete('SET NULL'); + table.string('subject_type').nullable(); // manual morph: model name + table.bigInteger('subject_id').unsigned().nullable(); + table.string('description').notNullable(); + table.json('properties').nullable(); + table.timestamp('created_at'); + table.timestamp('updated_at'); + table.index(['subject_type', 'subject_id']) + table.index('created_at') + }); + } + + async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/resources/js/Dataset.ts b/resources/js/Dataset.ts index 8bddc21..cd393ed 100644 --- a/resources/js/Dataset.ts +++ b/resources/js/Dataset.ts @@ -209,4 +209,13 @@ export interface Identifier { // STATE_DISABLED = 0, // STATE_VALIDATED = 1, // STATE_2FA_AUTHENTICATED = 1, -// } \ No newline at end of file +// } + +// resources/js/Dataset.ts (oder wo User definiert ist) +export interface Activity { + id: number | string; + type: string; + description: string; + user: string | null; + created_at: string; // ISO-String, relativeTime() erwartet das +} \ No newline at end of file diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index d823b8e..fde5c6e 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -32,6 +32,7 @@ import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton. import CardBoxDataset from '@/Components/CardBoxDataset.vue'; import type { User } from '@/Dataset'; import { stardust } from '@eidellev/adonis-stardust/client'; +import type { Activity } from '@/Dataset'; const mainService = MainService(); @@ -65,6 +66,7 @@ onMounted(async () => { mainService.fetchApi('clients'), mainService.fetchApi('authors'), mainService.fetchApi('datasets'), + mainService.fetchApi('activities'), loadChart(), ]); } catch (e) { @@ -176,16 +178,16 @@ const quickActions = [ }, ]; -type Activity = { - id: number | string; - description: string; - user?: string; - created_at: string; -}; +// type Activity = { +// id: number | string; +// description: string; +// user?: string; +// created_at: string; +// }; // Reads from the store if your backend provides it; otherwise renders an empty // state. Populate via e.g. mainService.fetchApi('activities') in onMounted. -const recentActivity = computed(() => (mainService as any).activities ?? []); +const recentActivity = computed(() => mainService.activities); const relativeTime = (iso: string): string => { const then = new Date(iso).getTime(); @@ -235,67 +237,6 @@ const relativeTime = (iso: string): string => { {{ loadError }} - -
-
- -
-
- -
-
- -
-
- - -
- - Latest {{ recentDatasets.length }} publications - - -
- -
-
- - - - Publications per month - - - -
-
-
-

Loading chart data...

-
-
-
- -
-
-

No chart data available

-
-
- + + + + + +
+ + Latest {{ recentDatasets.length }} publications + + +
+ +
+
+ + + + Publications per month + + + +
+
+
+

Loading chart data...

+
+
+
+ +
+
+

No chart data available

+
+
diff --git a/resources/js/Stores/main.ts b/resources/js/Stores/main.ts index 3a31057..aa1e84c 100644 --- a/resources/js/Stores/main.ts +++ b/resources/js/Stores/main.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import axios from 'axios'; -import { Dataset } from '@/Dataset'; +import { Activity, Dataset } from '@/Dataset'; import menu from '@/menu'; // import type Person from '#models/person'; @@ -133,6 +133,8 @@ export const MainService = defineStore('main', { used: 0, codes: [], + activities: [] as Array, // <-- neu + graphData: {}, }), actions: { @@ -203,6 +205,17 @@ export const MainService = defineStore('main', { }); }, + // async fetchApi(resource: string) { + // try { + // const { data } = await axios.get(`/api/${resource}`); + // // @ts-ignore – dynamischer Key + // this[resource] = data; + // } catch (error) { + // console.error(`Failed to fetch ${resource}`, error); + // } + // }, + + setState(state: any) { this.totpState = state; }, diff --git a/start/routes/api.ts b/start/routes/api.ts index ca3b079..5586b16 100644 --- a/start/routes/api.ts +++ b/start/routes/api.ts @@ -8,12 +8,15 @@ import AvatarController from '#controllers/Http/Api/AvatarController'; import UserController from '#controllers/Http/Api/UserController'; import CollectionsController from '#controllers/Http/Api/collections_controller'; import { middleware } from '../kernel.js'; +import ActivitiesController from '#app/controllers/activities_controller'; // Clean DOI URL routes (no /api prefix) // API routes with /api prefix router .group(() => { + router.get('activities', [ActivitiesController, 'index']).as('activities.index'); + router.get('clients', [UserController, 'getSubmitters']).as('client.index').use(middleware.auth()); router.get('authors', [AuthorsController, 'index']).as('author.index').use(middleware.auth()); router.get('datasets', [DatasetController, 'index']).as('dataset.index');