feat: scaffold offline-first mobile app (RN+Expo, expo-sqlite)

Capa API tipada de los 8 endpoints, BD local espejo del bundle + outbox
(operaciones y media) + cursor de sync, motor runSync (PUSH /sync ->
PUSH /media -> PULL bundle?since) con idempotencia por uuid y last-write-wins,
mutaciones de alto nivel (write local + encolar), sesion con token en
SecureStore, conectividad NetInfo y UI minima (Login -> Proyectos -> Detalle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
javier
2026-06-18 17:43:48 +02:00
co-authored by Claude Opus 4.8
parent 3f454b59a5
commit 4e9c7d059f
27 changed files with 9057 additions and 20 deletions
+114
View File
@@ -0,0 +1,114 @@
/**
* Cliente HTTP de bajo nivel sobre fetch.
*
* - Inyecta `Authorization: Bearer <token>` y `X-App-Version`.
* - Serializa/parsea JSON (excepto multipart, que pasa el FormData tal cual).
* - Lanza `ApiError` con el status para que las capas superiores decidan.
* - Notifica al `onUnauthorized` registrado cuando el backend responde 401
* (token caducado/revocado → la sesión debe forzar re-login).
*/
import { APP_VERSION, BASE_URL } from '../config';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public body?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
type TokenGetter = () => string | null;
let getToken: TokenGetter = () => null;
let onUnauthorized: (() => void) | null = null;
/** La sesión registra de dónde sacar el token y qué hacer ante un 401. */
export function configureClient(opts: {
getToken: TokenGetter;
onUnauthorized?: () => void;
}) {
getToken = opts.getToken;
onUnauthorized = opts.onUnauthorized ?? null;
}
export interface RequestOptions {
method?: 'GET' | 'POST';
/** Cuerpo JSON (ignorado si se pasa `form`). */
json?: unknown;
/** Cuerpo multipart/form-data (para /media). */
form?: FormData;
/** Parámetros de query; los valores se URL-encodean. */
query?: Record<string, string | number | undefined>;
/** Si false, no adjunta el token (p.ej. /login). Por defecto true. */
auth?: boolean;
signal?: AbortSignal;
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
let url = BASE_URL + path;
if (query) {
// Construcción manual (el polyfill de URL en RN es incompleto). encodeURIComponent
// codifica el `+` del offset horario del `since` como %2B, según exige el protocolo.
const parts: string[] = [];
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
}
if (parts.length) url += `?${parts.join('&')}`;
}
return url;
}
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', json, form, query, auth = true, signal } = opts;
const headers: Record<string, string> = {
Accept: 'application/json',
'X-App-Version': APP_VERSION,
};
if (auth) {
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
}
let body: BodyInit | undefined;
if (form) {
// No fijamos Content-Type: el runtime añade el boundary del multipart.
body = form as unknown as BodyInit;
} else if (json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(json);
}
const res = await fetch(buildUrl(path, query), { method, headers, body, signal });
if (res.status === 401 && auth) {
onUnauthorized?.();
}
const text = await res.text();
const parsed = text ? safeJson(text) : null;
if (!res.ok) {
const message =
(parsed && typeof parsed === 'object' && 'message' in parsed
? String((parsed as Record<string, unknown>).message)
: null) ?? `HTTP ${res.status}`;
throw new ApiError(res.status, message, parsed ?? text);
}
return parsed as T;
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Los 8 endpoints de la API (ver docs/openapi.yaml y brief §3), tipados.
*/
import { request } from './client';
import {
Bundle,
LoginRequest,
LoginResponse,
MediaParentEntity,
MediaUploadResult,
Operation,
Project,
SyncResponse,
Template,
User,
} from './types';
// --- Auth ---
export function login(payload: LoginRequest): Promise<LoginResponse> {
return request<LoginResponse>('/login', { method: 'POST', json: payload, auth: false });
}
export function me(): Promise<{ user: User }> {
return request<{ user: User }>('/me');
}
export function logout(): Promise<unknown> {
return request('/logout', { method: 'POST' });
}
// --- PULL ---
export function listProjects(): Promise<{ data?: Project[] } | Project[]> {
return request('/projects');
}
/**
* Bundle del proyecto. Sin `since` → snapshot completo; con `since` → delta
* + tombstones. El `+` del offset horario se URL-encodea en el cliente HTTP.
*/
export function getBundle(projectId: number, since?: string): Promise<Bundle> {
return request<Bundle>(`/projects/${projectId}/bundle`, {
query: since ? { since } : undefined,
});
}
export function getTemplates(since?: string): Promise<{ data?: Template[] } | Template[]> {
return request('/templates', { query: since ? { since } : undefined });
}
// --- PUSH ---
export function sync(operations: Operation[]): Promise<SyncResponse> {
return request<SyncResponse>('/sync', { method: 'POST', json: { operations } });
}
// --- Media ---
export interface MediaUploadInput {
uuid: string;
parentEntity: MediaParentEntity;
parentId: number;
/** URI local del fichero (file:// ...). */
fileUri: string;
fileName?: string;
mimeType?: string;
category?: 'image' | 'document' | 'other';
description?: string;
}
export function uploadMedia(input: MediaUploadInput): Promise<MediaUploadResult> {
const form = new FormData();
form.append('uuid', input.uuid);
form.append('parent_entity', input.parentEntity);
form.append('parent_id', String(input.parentId));
if (input.category) form.append('category', input.category);
if (input.description) form.append('description', input.description);
// En React Native, FormData acepta { uri, name, type } para ficheros.
form.append('file', {
uri: input.fileUri,
name: input.fileName ?? input.fileUri.split('/').pop() ?? 'upload',
type: input.mimeType ?? 'application/octet-stream',
} as unknown as Blob);
return request<MediaUploadResult>('/media', { method: 'POST', form });
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Tipos del contrato de la API (fuente de verdad: docs/openapi.yaml).
* Las entidades del bundle se tipan de forma laxa (campos opcionales) porque
* el backend puede añadir columnas; sólo fijamos lo que la app consume.
*/
// ----- Auth -----
export interface User {
id: number;
name: string;
email: string;
roles: string[];
permissions: string[];
}
export interface LoginRequest {
email: string;
password: string;
device_name: string;
app_version?: string;
}
export interface LoginResponse {
token: string;
user: User;
}
// ----- Entidades del bundle (PULL) -----
export interface Project {
id: number;
reference?: string;
name: string;
address?: string | null;
lat?: number | null;
lng?: number | null;
status?: string;
updated_at: string;
}
export interface Phase {
id: number;
name: string;
order?: number;
color?: string | null;
progress_percent?: number;
updated_at: string;
}
export interface Layer {
id: number;
phase_id: number;
name: string;
color?: string | null;
updated_at: string;
}
export interface Feature {
id: number;
layer_id: number;
name: string;
geometry?: unknown; // GeoJSON
status?: string;
progress?: number;
responsible?: string | null;
template_id?: number | null;
updated_at: string;
}
export interface Inspection {
id: number;
feature_id: number;
layer_id?: number;
template_id?: number | null;
user_id?: number;
data?: Record<string, unknown>;
status?: string;
result?: string | null;
notes?: string | null;
created_at?: string;
updated_at: string;
}
export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
export type IssueStatus = 'open' | 'in_review' | 'resolved' | 'closed';
export type IssueType = 'defect' | 'safety' | 'quality' | 'documentation' | 'other';
export interface Issue {
id: number;
feature_id?: number | null;
title: string;
description?: string | null;
status?: IssueStatus;
priority?: IssuePriority;
type?: IssueType;
reported_by?: number;
assigned_to?: number | null;
resolved_at?: string | null;
updated_at: string;
}
export interface IssueTask {
id: number;
issue_id: number;
title: string;
is_done?: boolean;
done_at?: string | null;
done_by?: number | null;
assigned_to?: number | null;
due_date?: string | null;
order?: number;
updated_at: string;
}
export interface IssueComment {
id: number;
issue_id: number;
user_id?: number;
body: string;
created_at?: string;
updated_at: string;
}
export interface Template {
id: number;
project_id?: number;
phase_id?: number | null;
name: string;
description?: string | null;
fields?: unknown[];
version: number | string;
hash?: string;
updated_at: string;
}
export type MediaParentEntity =
| 'feature'
| 'issue'
| 'issue_task'
| 'issue_comment'
| 'project'
| 'phase'
| 'layer';
export interface Media {
id: number;
uuid: string;
parent_entity: MediaParentEntity;
parent_id: number;
url: string;
name?: string;
file_type?: string;
category?: 'image' | 'document' | 'other';
updated_at: string;
}
/** Tombstones: ids borrados por entidad (sólo en respuestas delta). */
export interface DeletedTombstones {
phases?: number[];
layers?: number[];
features?: number[];
inspections?: number[];
issues?: number[];
issue_tasks?: number[];
issue_comments?: number[];
}
export interface Bundle {
server_time: string;
project: Project;
phases: Phase[];
layers: Layer[];
features: Feature[];
inspections: Inspection[];
issues: Issue[];
issue_tasks: IssueComment[] | IssueTask[];
issue_comments: IssueComment[];
templates: Template[];
media: Media[];
deleted: DeletedTombstones;
}
// ----- PUSH (/sync) -----
export type SyncEntity =
| 'progress_update'
| 'inspection'
| 'issue'
| 'issue_task'
| 'issue_comment'
| 'feature';
export type SyncOp = 'create' | 'update';
export interface Operation {
entity: SyncEntity;
op: SyncOp;
uuid: string;
client_updated_at: string;
data: Record<string, unknown>;
}
export type OperationStatus = 'applied' | 'duplicate' | 'conflict' | 'error';
export interface OperationResult {
uuid: string;
status: OperationStatus;
server_id?: number | null;
error?: string | null;
server?: Record<string, unknown> | null;
}
export interface SyncResponse {
results: OperationResult[];
}
// ----- Media (/media) -----
export interface MediaUploadResult {
status: 'applied' | 'duplicate';
media?: Media;
}