297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
import { mutationCsrfToken } from './sync'
|
|
|
|
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
|
|
|
|
export interface SessionListItem {
|
|
identity: string
|
|
title: string
|
|
date: string | null
|
|
state: string
|
|
occurrence_count: number
|
|
sort_timestamp: string
|
|
}
|
|
|
|
export interface SessionPage {
|
|
api_version: 1
|
|
kind: string
|
|
offset: number
|
|
more: boolean
|
|
next_offset: number
|
|
items: SessionListItem[]
|
|
}
|
|
|
|
export interface PreparationOccurrence {
|
|
entry_id?: string
|
|
exercise_id: string
|
|
exercise_name?: string
|
|
equipment_id: string | null
|
|
recording_mode?: string
|
|
tracking_mode?: string
|
|
load_mode: 'none' | 'external' | 'assistance'
|
|
rest_seconds: number
|
|
target_sets: number | null
|
|
target_reps: number | null
|
|
target_duration_seconds: number | null
|
|
target_weight_kg: number | null
|
|
notes: string | null
|
|
}
|
|
|
|
export interface SessionDetail {
|
|
api_version: 1
|
|
kind: 'preparation' | 'proposal'
|
|
identity: string
|
|
revision_id: string
|
|
title: string
|
|
planned_for: string | null
|
|
session_type: 'training' | 'max_test'
|
|
notes: string | null
|
|
source_fingerprint: string | null
|
|
source_proposal_id?: string | null
|
|
source_proposal_title?: string | null
|
|
withdrawn_at?: string | null
|
|
withdrawal_id?: string | null
|
|
withdrawal_acknowledged_at?: string | null
|
|
state: string
|
|
occurrences: PreparationOccurrence[]
|
|
}
|
|
|
|
export interface CatalogChoice {
|
|
exercise_id: string
|
|
name: string
|
|
recording_mode: 'sets' | 'continuous'
|
|
tracking_mode: 'reps' | 'duration'
|
|
data_fields: number
|
|
}
|
|
|
|
export interface PreparationInput {
|
|
title: string
|
|
session_type: 'training' | 'max_test'
|
|
planned_for: string | null
|
|
notes: string | null
|
|
editing_state: 'draft' | 'ready'
|
|
occurrences: PreparationOccurrence[]
|
|
source_proposal_id?: string
|
|
source_payload_sha256?: string
|
|
}
|
|
|
|
function object(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function validListItem(value: unknown): value is SessionListItem {
|
|
if (!object(value)) return false
|
|
return typeof value.identity === 'string' && typeof value.title === 'string' &&
|
|
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
|
|
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
|
|
typeof value.sort_timestamp === 'string'
|
|
}
|
|
|
|
export async function fetchSessionPage(
|
|
collection: SessionCollection,
|
|
offset = 0,
|
|
search = '',
|
|
signal?: AbortSignal,
|
|
): Promise<SessionPage> {
|
|
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
|
|
if (search.trim()) query.set('search', search.trim())
|
|
const response = await fetch(`/api/v1/sessions/${collection}?${query}`, {
|
|
headers: { Accept: 'application/json' }, signal,
|
|
})
|
|
if (!response.ok) throw new Error(`liste HTTP ${response.status}`)
|
|
const value: unknown = await response.json()
|
|
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
|
|
!value.items.every(validListItem) || typeof value.more !== 'boolean' ||
|
|
!Number.isSafeInteger(value.next_offset)) throw new TypeError('liste de séances invalide')
|
|
return value as unknown as SessionPage
|
|
}
|
|
|
|
export async function fetchAllSessionPages(
|
|
collection: SessionCollection,
|
|
search = '',
|
|
signal?: AbortSignal,
|
|
): Promise<SessionListItem[]> {
|
|
const items: SessionListItem[] = []
|
|
const identities = new Set<string>()
|
|
let offset = 0
|
|
|
|
for (;;) {
|
|
const page = await fetchSessionPage(collection, offset, search, signal)
|
|
if (page.offset !== offset) throw new TypeError('curseur de séances obsolète')
|
|
for (const item of page.items) {
|
|
if (identities.has(item.identity)) throw new TypeError('pagination de séances incohérente')
|
|
identities.add(item.identity)
|
|
items.push(item)
|
|
}
|
|
if (!page.more) return items
|
|
if (page.next_offset <= offset || page.items.length === 0) {
|
|
throw new TypeError('curseur de séances obsolète')
|
|
}
|
|
offset = page.next_offset
|
|
}
|
|
}
|
|
|
|
export async function fetchSessionDetail(
|
|
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
|
|
): Promise<SessionDetail> {
|
|
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
|
|
headers: { Accept: 'application/json' }, signal,
|
|
})
|
|
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
|
|
const value: unknown = await response.json()
|
|
if (!object(value) || value.api_version !== 1 || value.kind !== kind ||
|
|
value.identity !== identity || typeof value.revision_id !== 'string' ||
|
|
typeof value.title !== 'string' || !Array.isArray(value.occurrences)) {
|
|
throw new TypeError('fiche de séance invalide')
|
|
}
|
|
return value as unknown as SessionDetail
|
|
}
|
|
|
|
export async function fetchReadonlySessionDetail(
|
|
kind: 'draft' | 'history', identity: string, signal?: AbortSignal,
|
|
): Promise<Record<string, unknown>> {
|
|
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
|
|
headers: { Accept: 'application/json' }, signal,
|
|
})
|
|
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
|
|
const value: unknown = await response.json()
|
|
if (!object(value) || value.api_version !== 1 || value.kind !== kind || value.identity !== identity) {
|
|
throw new TypeError('fiche de séance invalide')
|
|
}
|
|
return value
|
|
}
|
|
|
|
export async function fetchCatalog(search = '', signal?: AbortSignal): Promise<CatalogChoice[]> {
|
|
const query = new URLSearchParams({ limit: '64', search })
|
|
const response = await fetch(`/api/v1/sessions/catalog?${query}`, {
|
|
headers: { Accept: 'application/json' }, signal,
|
|
})
|
|
if (!response.ok) throw new Error(`catalogue HTTP ${response.status}`)
|
|
const value: unknown = await response.json()
|
|
if (!object(value) || !Array.isArray(value.items)) throw new TypeError('catalogue invalide')
|
|
return value.items as CatalogChoice[]
|
|
}
|
|
|
|
function requestId(): string {
|
|
const bytes = new Uint8Array(16)
|
|
crypto.getRandomValues(bytes)
|
|
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
|
|
}
|
|
|
|
export async function savePreparation(
|
|
input: PreparationInput,
|
|
identity?: string,
|
|
revision?: string,
|
|
): Promise<{ preparation_id: string; revision_id: string }> {
|
|
const csrf = await mutationCsrfToken()
|
|
const response = await fetch(identity
|
|
? `/api/v1/sessions/preparation/${encodeURIComponent(identity)}`
|
|
: '/api/v1/sessions/preparations', {
|
|
method: identity ? 'PUT' : 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Trainlog-CSRF-Token': csrf,
|
|
'X-Trainlog-Request-ID': requestId(),
|
|
...(identity && revision ? { 'If-Match': `"${revision}"` } : {}),
|
|
},
|
|
body: JSON.stringify(input),
|
|
})
|
|
const value: unknown = await response.json()
|
|
if (!response.ok) {
|
|
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
|
|
throw new Error(reason)
|
|
}
|
|
if (!object(value) || typeof value.preparation_id !== 'string' ||
|
|
typeof value.revision_id !== 'string') throw new TypeError('réponse de sauvegarde invalide')
|
|
return { preparation_id: value.preparation_id, revision_id: value.revision_id }
|
|
}
|
|
|
|
export async function prepareForAndroid(identity: string, revision: string): Promise<void> {
|
|
const csrf = await mutationCsrfToken()
|
|
const response = await fetch(
|
|
`/api/v1/sessions/preparation/${encodeURIComponent(identity)}/deliver`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Trainlog-CSRF-Token': csrf,
|
|
'X-Trainlog-Request-ID': requestId(),
|
|
'If-Match': `"${revision}"`,
|
|
},
|
|
body: '{}',
|
|
})
|
|
const value: unknown = await response.json()
|
|
if (!response.ok) {
|
|
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
|
|
throw new Error(reason)
|
|
}
|
|
}
|
|
|
|
export interface PreparationWithdrawalResult {
|
|
preparation_id: string
|
|
revision_id: string
|
|
withdrawal_id: string
|
|
android_cancellation: 'pending' | 'not_required'
|
|
}
|
|
|
|
export async function withdrawPreparation(
|
|
identity: string,
|
|
revision: string,
|
|
): Promise<PreparationWithdrawalResult> {
|
|
const csrf = await mutationCsrfToken()
|
|
const response = await fetch(
|
|
`/api/v1/sessions/preparation/${encodeURIComponent(identity)}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Trainlog-CSRF-Token': csrf,
|
|
'X-Trainlog-Request-ID': requestId(),
|
|
'If-Match': `"${revision}"`,
|
|
},
|
|
body: '{}',
|
|
})
|
|
const value: unknown = await response.json()
|
|
if (!response.ok) {
|
|
const reason = object(value) && typeof value.error === 'string'
|
|
? value.error : `HTTP ${response.status}`
|
|
throw new Error(reason)
|
|
}
|
|
if (!object(value) || value.preparation_id !== identity || value.revision_id !== revision ||
|
|
typeof value.withdrawal_id !== 'string' ||
|
|
!['pending', 'not_required'].includes(String(value.android_cancellation))) {
|
|
throw new TypeError('réponse de suppression invalide')
|
|
}
|
|
return value as unknown as PreparationWithdrawalResult
|
|
}
|
|
|
|
export type DeletableSessionKind = 'proposal' | 'draft' | 'history'
|
|
|
|
export async function deleteSessionResource(
|
|
kind: DeletableSessionKind,
|
|
identity: string,
|
|
revision: string,
|
|
): Promise<void> {
|
|
const csrf = await mutationCsrfToken()
|
|
const response = await fetch(
|
|
`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Trainlog-CSRF-Token': csrf,
|
|
'X-Trainlog-Request-ID': requestId(),
|
|
'If-Match': `"${revision}"`,
|
|
},
|
|
body: '{}',
|
|
},
|
|
)
|
|
const value: unknown = await response.json()
|
|
if (!response.ok) {
|
|
const reason = object(value) && typeof value.error === 'string'
|
|
? value.error
|
|
: `HTTP ${response.status}`
|
|
throw new Error(reason)
|
|
}
|
|
}
|