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:
co-authored by
Claude Opus 4.8
parent
3f454b59a5
commit
4e9c7d059f
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user