shinkan-jinkendo/frontend/src/api/exercises.js
Lars e759076a6c
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
chore(version): update version and changelog for release 0.8.136
- Bumped APP_VERSION to 0.8.136 and updated the changelog to reflect recent changes.
- Fixed the Mandanten-Header handling in `api/exercises.js` for improved API requests.
- Continued Frontend Phase 4 with the addition of the `exercises.js` module, enhancing the API structure.
- Updated architecture documentation to include details on the new `exercises.js` API and its integration with the existing client structure.
- Enhanced `utils/api.js` to re-export the new exercises module, streamlining API access.
2026-05-14 21:49:56 +02:00

594 lines
20 KiB
JavaScript

/**
* Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen.
*/
import { stripHtmlToText } from '../utils/htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js'
/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */
function withActiveClubHeaders(headers = {}) {
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
}
return { ...headers }
}
// ============================================================================
// Exercises
// ============================================================================
export async function listExercises(filters = {}) {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return
if (typeof v === 'boolean') {
q.set(k, v ? 'true' : 'false')
return
}
if (Array.isArray(v)) {
if (v.length === 0) return
v.forEach((item) => {
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
q.append(k, String(item))
}
})
return
}
if (String(v).trim() !== '') q.set(k, String(v))
})
const query = q.toString()
return request(`/api/exercises${query ? '?' + query : ''}`)
}
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
export function buildExerciseApiPayload(formData, extras = {}) {
const num = (v) => (v === '' || v == null ? null : Number(v))
const goalHtml = formData.goal || ''
const execHtml = formData.execution || ''
const goalText = stripHtmlToText(goalHtml)
const execText = stripHtmlToText(execHtml)
if (!goalText && !execText) {
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
}
const mapFocus = (formData.focus_areas_multi || [])
.filter((x) => x && x.focus_area_id)
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
const mapStyles = (formData.training_styles_multi || [])
.filter((x) => x && x.training_style_id)
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
const mapTTypes = (formData.training_types_multi || [])
.filter((x) => x && x.training_type_id)
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
const mapTg = (formData.target_groups_multi || [])
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
execution: execHtml.trim() ? execHtml : null,
preparation: formData.preparation || null,
trainer_notes: formData.trainer_notes || null,
duration_min: num(formData.duration_min),
duration_max: num(formData.duration_max),
group_size_min: num(formData.group_size_min),
group_size_max: num(formData.group_size_max),
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
focus_areas_multi: mapFocus,
training_styles_multi: mapStyles,
training_types_multi: mapTTypes,
target_groups_multi: mapTg,
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
visibility: visibilityNorm,
status: formData.status || 'draft',
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
...extras,
}
const isCombo = payload.exercise_kind === 'combination'
if (isCombo) {
let mpObj = {}
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
if (mpRaw) {
try {
const parsed = JSON.parse(mpRaw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
}
mpObj = parsed
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
}
throw e
}
}
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
function parseTimingField(raw) {
if (raw === '' || raw == null || raw === undefined) return undefined
const n = parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
let ids = Array.isArray(row.candidate_exercise_ids)
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
ids = row.idsText
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
}
combination_slots.push({
slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
candidate_exercise_ids: ids,
})
}
const slot_profiles_v1_next = []
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const o = { slot_index: i }
const advanceMode = normalizeAdvanceMode(row.advance_mode)
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const allowInterSeriesPause =
advanceMode === 'timed' ||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
if (
rsc !== undefined &&
rsc >= 1 &&
(advanceMode === 'rep' || advanceMode === 'manual')
) {
o.rep_series_count = Math.round(rsc)
}
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
}
payload.method_archetype = (formData.method_archetype || '').trim() || null
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
payload.method_archetype = null
payload.method_profile = {}
}
return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({})
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 409 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
)
e.code = d.code
e.status = 409
e.payload = d
throw e
}
if (response.status === 413) {
const nginx = (text || '').toLowerCase().includes('nginx')
throw new Error(
nginx
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
)
}
const msg =
typeof d === 'string'
? d
: d != null && typeof d === 'object' && typeof d.message === 'string'
? d.message
: d != null
? JSON.stringify(d)
: text && text.length < 400 && !/^\s*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
export async function createExercise(data) {
return request('/api/exercises', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExercise(id, data) {
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}