add now features
This commit is contained in:
@@ -74,6 +74,7 @@ import trainingRoutes from './routes/training.routes';
|
||||
import vehicleRoutes from './routes/vehicle.routes';
|
||||
import incidentRoutes from './routes/incident.routes';
|
||||
import equipmentRoutes from './routes/equipment.routes';
|
||||
import nextcloudRoutes from './routes/nextcloud.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -83,6 +84,7 @@ app.use('/api/training', trainingRoutes);
|
||||
app.use('/api/vehicles', vehicleRoutes);
|
||||
app.use('/api/incidents', incidentRoutes);
|
||||
app.use('/api/equipment', equipmentRoutes);
|
||||
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -32,6 +32,7 @@ interface EnvironmentConfig {
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
nextcloudUrl: string;
|
||||
}
|
||||
|
||||
const environment: EnvironmentConfig = {
|
||||
@@ -61,6 +62,7 @@ const environment: EnvironmentConfig = {
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here',
|
||||
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
|
||||
},
|
||||
nextcloudUrl: process.env.NEXTCLOUD_URL || '',
|
||||
};
|
||||
|
||||
export default environment;
|
||||
|
||||
85
backend/src/controllers/nextcloud.controller.ts
Normal file
85
backend/src/controllers/nextcloud.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import nextcloudService from '../services/nextcloud.service';
|
||||
import userService from '../services/user.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const PollRequestSchema = z.object({
|
||||
pollEndpoint: z.string().url(),
|
||||
pollToken: z.string().min(1),
|
||||
});
|
||||
|
||||
class NextcloudController {
|
||||
async initiateConnect(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await nextcloudService.initiateLoginFlow();
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('initiateConnect error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Verbindung konnte nicht gestartet werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async pollConnect(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const parsed = PollRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await nextcloudService.pollLoginFlow(parsed.data.pollEndpoint, parsed.data.pollToken);
|
||||
|
||||
if (!result) {
|
||||
res.status(200).json({ success: true, data: { completed: false } });
|
||||
return;
|
||||
}
|
||||
|
||||
await userService.updateNextcloudCredentials(req.user!.id, result.loginName, result.appPassword);
|
||||
res.status(200).json({ success: true, data: { completed: true } });
|
||||
} catch (error) {
|
||||
logger.error('pollConnect error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Abfrage fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
async getConversations(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { totalUnread, conversations } = await nextcloudService.getConversations(
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
res.status(200).json({ success: true, data: { connected: true, totalUnread, conversations } });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
logger.error('getConversations error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Gespräche konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error) {
|
||||
logger.error('disconnect error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NextcloudController();
|
||||
30
backend/src/database/migrations/012_create_ausbildung.sql
Normal file
30
backend/src/database/migrations/012_create_ausbildung.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Add FDISK Standesbuch-Nr to mitglieder_profile for sync matching
|
||||
ALTER TABLE mitglieder_profile
|
||||
ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32) UNIQUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mitglieder_fdisk_standesbuch_nr
|
||||
ON mitglieder_profile(fdisk_standesbuch_nr);
|
||||
|
||||
-- Qualifications synced from FDISK
|
||||
CREATE TABLE IF NOT EXISTS ausbildung (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kursname VARCHAR(255) NOT NULL,
|
||||
kurs_datum DATE,
|
||||
ablaufdatum DATE,
|
||||
ort VARCHAR(255),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'abgeschlossen' CHECK (status IN (
|
||||
'abgeschlossen', 'in_bearbeitung', 'abgelaufen'
|
||||
)),
|
||||
-- Composite key from FDISK to prevent duplicates on re-sync
|
||||
fdisk_sync_key VARCHAR(255),
|
||||
bemerkung TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
CONSTRAINT uq_ausbildung_user_fdisk_key UNIQUE (user_id, fdisk_sync_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ausbildung_user_id ON ausbildung(user_id);
|
||||
|
||||
CREATE TRIGGER update_ausbildung_updated_at BEFORE UPDATE ON ausbildung
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration 013: Add Nextcloud credentials to users
|
||||
-- Stores per-user Nextcloud login name and app password for Nextcloud API access.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS nextcloud_login_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS nextcloud_app_password TEXT;
|
||||
@@ -9,6 +9,8 @@ export interface User {
|
||||
profile_picture_url?: string;
|
||||
refresh_token?: string;
|
||||
refresh_token_expires_at?: Date;
|
||||
nextcloud_login_name?: string;
|
||||
nextcloud_app_password?: string;
|
||||
is_active: boolean;
|
||||
last_login_at?: Date;
|
||||
created_at: Date;
|
||||
|
||||
12
backend/src/routes/nextcloud.routes.ts
Normal file
12
backend/src/routes/nextcloud.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import nextcloudController from '../controllers/nextcloud.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, nextcloudController.getConversations.bind(nextcloudController));
|
||||
router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(nextcloudController));
|
||||
router.post('/poll', authenticate, nextcloudController.pollConnect.bind(nextcloudController));
|
||||
router.delete('/connect', authenticate, nextcloudController.disconnect.bind(nextcloudController));
|
||||
|
||||
export default router;
|
||||
152
backend/src/services/nextcloud.service.ts
Normal file
152
backend/src/services/nextcloud.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import axios from 'axios';
|
||||
import environment from '../config/environment';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface NextcloudLastMessage {
|
||||
text: string;
|
||||
author: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface NextcloudConversation {
|
||||
token: string;
|
||||
displayName: string;
|
||||
unreadMessages: number;
|
||||
lastActivity: number;
|
||||
lastMessage: NextcloudLastMessage | null;
|
||||
type: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ConversationsResult {
|
||||
totalUnread: number;
|
||||
conversations: NextcloudConversation[];
|
||||
}
|
||||
|
||||
interface LoginFlowResult {
|
||||
loginUrl: string;
|
||||
pollToken: string;
|
||||
pollEndpoint: string;
|
||||
}
|
||||
|
||||
interface LoginFlowCredentials {
|
||||
loginName: string;
|
||||
appPassword: string;
|
||||
}
|
||||
|
||||
async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${baseUrl}/index.php/login/v2`);
|
||||
return {
|
||||
loginUrl: response.data.login,
|
||||
pollToken: response.data.poll.token,
|
||||
pollEndpoint: response.data.poll.endpoint,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('Nextcloud Login Flow v2 initiation failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
}
|
||||
logger.error('NextcloudService.initiateLoginFlow failed', { error });
|
||||
throw new Error('Failed to initiate Nextcloud login flow');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> {
|
||||
try {
|
||||
const response = await axios.post(pollEndpoint, `token=${pollToken}`, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
return {
|
||||
loginName: response.data.loginName,
|
||||
appPassword: response.data.appPassword,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('Nextcloud Login Flow v2 poll failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
}
|
||||
logger.error('NextcloudService.pollLoginFlow failed', { error });
|
||||
throw new Error('Failed to poll Nextcloud login flow');
|
||||
}
|
||||
}
|
||||
|
||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rooms: any[] = response.data?.ocs?.data ?? [];
|
||||
|
||||
const filtered = rooms.filter((r: any) => r.type !== 4);
|
||||
|
||||
const totalUnread = filtered.reduce(
|
||||
(sum: number, r: any) => sum + (r.unreadMessages ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const sorted = [...filtered].sort(
|
||||
(a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0),
|
||||
);
|
||||
|
||||
const conversations: NextcloudConversation[] = sorted.slice(0, 3).map((r: any) => ({
|
||||
token: r.token,
|
||||
displayName: r.displayName,
|
||||
unreadMessages: r.unreadMessages ?? 0,
|
||||
lastActivity: r.lastActivity ?? 0,
|
||||
lastMessage: r.lastMessage
|
||||
? {
|
||||
text: r.lastMessage.message ?? '',
|
||||
author: r.lastMessage.actorDisplayName ?? '',
|
||||
timestamp: r.lastMessage.timestamp ?? 0,
|
||||
}
|
||||
: null,
|
||||
type: r.type,
|
||||
url: `${baseUrl}/call/${r.token}`,
|
||||
}));
|
||||
|
||||
return { totalUnread, conversations };
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const err = new Error('Nextcloud app password is invalid');
|
||||
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||
throw err;
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('Nextcloud API request failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||
}
|
||||
logger.error('NextcloudService.getConversations failed', { error });
|
||||
throw new Error('Failed to fetch Nextcloud conversations');
|
||||
}
|
||||
}
|
||||
|
||||
export default { initiateLoginFlow, pollLoginFlow, getConversations };
|
||||
@@ -273,6 +273,55 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateNextcloudCredentials(userId: string, loginName: string, appPassword: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE users SET nextcloud_login_name = $2, nextcloud_app_password = $3 WHERE id = $1`,
|
||||
[userId, loginName, appPassword],
|
||||
);
|
||||
logger.debug('Updated Nextcloud credentials', { userId });
|
||||
} catch (error) {
|
||||
logger.error('Error updating Nextcloud credentials', { error, userId });
|
||||
throw new Error('Failed to update Nextcloud credentials');
|
||||
}
|
||||
}
|
||||
|
||||
async getNextcloudCredentials(userId: string): Promise<{ loginName: string; appPassword: string } | null> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT nextcloud_login_name, nextcloud_app_password FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row.nextcloud_login_name || !row.nextcloud_app_password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { loginName: row.nextcloud_login_name, appPassword: row.nextcloud_app_password };
|
||||
} catch (error) {
|
||||
logger.error('Error getting Nextcloud credentials', { error, userId });
|
||||
throw new Error('Failed to get Nextcloud credentials');
|
||||
}
|
||||
}
|
||||
|
||||
async clearNextcloudCredentials(userId: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE users SET nextcloud_login_name = NULL, nextcloud_app_password = NULL WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
logger.debug('Cleared Nextcloud credentials', { userId });
|
||||
} catch (error) {
|
||||
logger.error('Error clearing Nextcloud credentials', { error, userId });
|
||||
throw new Error('Failed to clear Nextcloud credentials');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Authentik groups for a user
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user