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;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Sesión: almacenamiento seguro del token (SecureStore), usuario actual y
* acciones login/logout. Registra el cliente HTTP para que inyecte el token y
* reaccione a un 401 (token caducado → re-login).
*/
import * as SecureStore from 'expo-secure-store';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { configureClient } from '../api/client';
import * as api from '../api/endpoints';
import { User } from '../api/types';
import { APP_VERSION } from '../config';
import { wipeDatabase } from '../db/database';
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
interface SessionState {
ready: boolean;
token: string | null;
user: User | null;
signIn: (email: string, password: string, deviceName: string) => Promise<void>;
signOut: () => Promise<void>;
}
const SessionContext = createContext<SessionState | null>(null);
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
// Ref para que el getToken del cliente HTTP siempre lea el valor vigente.
const tokenRef = useRef<string | null>(null);
tokenRef.current = token;
const clear = useCallback(async () => {
setToken(null);
setUser(null);
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
}, []);
// Configura el cliente HTTP una sola vez.
useEffect(() => {
configureClient({
getToken: () => tokenRef.current,
onUnauthorized: () => {
// Token inválido/caducado: limpiamos sesión (la UI llevará al login).
void clear();
},
});
}, [clear]);
// Rehidrata la sesión al arrancar.
useEffect(() => {
(async () => {
try {
const [t, u] = await Promise.all([
SecureStore.getItemAsync(TOKEN_KEY),
SecureStore.getItemAsync(USER_KEY),
]);
if (t) {
tokenRef.current = t;
setToken(t);
setUser(u ? (JSON.parse(u) as User) : null);
}
} finally {
setReady(true);
}
})();
}, []);
const signIn = useCallback(
async (email: string, password: string, deviceName: string) => {
const res = await api.login({
email,
password,
device_name: deviceName,
app_version: APP_VERSION,
});
tokenRef.current = res.token;
setToken(res.token);
setUser(res.user);
await SecureStore.setItemAsync(TOKEN_KEY, res.token);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(res.user));
},
[],
);
const signOut = useCallback(async () => {
try {
await api.logout();
} catch {
// Aunque falle la revocación remota, limpiamos local.
}
await clear();
await wipeDatabase();
}, [clear]);
const value = useMemo<SessionState>(
() => ({ ready, token, user, signIn, signOut }),
[ready, token, user, signIn, signOut],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession(): SessionState {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error('useSession debe usarse dentro de <SessionProvider>');
return ctx;
}
export function hasPermission(user: User | null, permission: string): boolean {
return Boolean(user?.permissions?.includes(permission));
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { OutboxCounts } from '../db/outbox';
import { useIsOnline } from '../net/connectivity';
export function SyncStatusBar({
counts,
syncing,
}: {
counts: OutboxCounts;
syncing: boolean;
}) {
const online = useIsOnline();
const pendientes = counts.pending + counts.mediaPending;
return (
<View style={[styles.bar, { backgroundColor: online ? '#1f6f43' : '#8a6d00' }]}>
{syncing && <ActivityIndicator color="#fff" size="small" />}
<Text style={styles.text}>
{online ? 'En línea' : 'Sin conexión'}
{pendientes > 0 ? ` · ${pendientes} en cola` : ' · al día'}
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
</Text>
</View>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 8,
},
text: { color: '#fff', fontSize: 13, fontWeight: '600' },
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Configuración de la app.
*
* BASE_URL: raíz de la API v1. En despliegue real apunta a `https://<host>/api/v1`.
* Ojo: desde un dispositivo físico/emulador "localhost" NO es la máquina de desarrollo:
* - Emulador Android: usa `http://10.0.2.2/...`
* - Dispositivo físico: usa la IP LAN del PC, p.ej. `http://192.168.1.50/...`
* Ajusta DEV_HOST a tu entorno o sobreescribe BASE_URL.
*/
const DEV_HOST = 'http://10.0.2.2/construprogress/public';
export const BASE_URL = `${DEV_HOST}/api/v1`;
/** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */
export const APP_VERSION = '1.0.0';
/** Nombre de la BD SQLite local. */
export const DB_NAME = 'avante.db';
/** Ability que el backend exige al token (ver brief §2). */
export const TOKEN_ABILITY = 'mobile-sync';
+53
View File
@@ -0,0 +1,53 @@
/**
* Apertura y migración de la BD local. Singleton: una sola conexión por proceso.
*/
import * as SQLite from 'expo-sqlite';
import { DB_NAME } from '../config';
import { SCHEMA_SQL, SCHEMA_VERSION } from './schema';
let dbPromise: Promise<SQLite.SQLiteDatabase> | null = null;
export function getDb(): Promise<SQLite.SQLiteDatabase> {
if (!dbPromise) {
dbPromise = openAndMigrate();
}
return dbPromise;
}
async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
const db = await SQLite.openDatabaseAsync(DB_NAME);
await db.execAsync(SCHEMA_SQL);
// Versionado por PRAGMA user_version (migraciones futuras van aquí).
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
const current = row?.user_version ?? 0;
if (current < SCHEMA_VERSION) {
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
}
return db;
}
/** Borra todos los datos locales (logout / cambio de usuario). */
export async function wipeDatabase(): Promise<void> {
const db = await getDb();
const tables = [
'meta',
'projects',
'phases',
'layers',
'features',
'inspections',
'issues',
'issue_tasks',
'issue_comments',
'templates',
'media',
'outbox',
'media_outbox',
];
await db.withTransactionAsync(async () => {
for (const t of tables) {
await db.execAsync(`DELETE FROM ${t}`);
}
});
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Outbox: cola de salida de operaciones (/sync) y de ficheros (/media).
* Cada cambio offline se encola aquí; el motor de sync la vacía con red.
*/
import { getDb } from './database';
import { Operation, OperationResult, SyncEntity, SyncOp } from '../api/types';
import { newUuid, nowIso } from '../sync/uuid';
export interface OutboxRow extends Operation {
status: 'pending' | 'sent' | 'conflict' | 'error';
server_id: number | null;
error: string | null;
server_payload: string | null;
attempts: number;
created_at: string;
}
/** Encola una operación. Devuelve su uuid (idempotencia). */
export async function enqueueOperation(
entity: SyncEntity,
op: SyncOp,
data: Record<string, unknown>,
): Promise<string> {
const db = await getDb();
const uuid = newUuid();
const clientUpdatedAt = nowIso();
await db.runAsync(
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
uuid,
entity,
op,
JSON.stringify(data),
clientUpdatedAt,
clientUpdatedAt,
);
return uuid;
}
interface RawOutbox {
uuid: string;
entity: SyncEntity;
op: SyncOp;
data: string;
client_updated_at: string;
status: OutboxRow['status'];
server_id: number | null;
error: string | null;
server_payload: string | null;
attempts: number;
created_at: string;
}
/** Operaciones pendientes de enviar, en orden de creación. */
export async function getPendingOperations(limit = 100): Promise<Operation[]> {
const db = await getDb();
const rows = await db.getAllAsync<RawOutbox>(
`SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
limit,
);
return rows.map((r) => ({
entity: r.entity,
op: r.op,
uuid: r.uuid,
client_updated_at: r.client_updated_at,
data: JSON.parse(r.data) as Record<string, unknown>,
}));
}
/** Aplica el resultado de /sync de una operación a su fila del outbox. */
export async function applyOperationResult(result: OperationResult): Promise<void> {
const db = await getDb();
if (result.status === 'applied' || result.status === 'duplicate') {
await db.runAsync(
`UPDATE outbox SET status = 'sent', server_id = ?, error = NULL WHERE uuid = ?`,
result.server_id ?? null,
result.uuid,
);
} else if (result.status === 'conflict') {
await db.runAsync(
`UPDATE outbox SET status = 'conflict', server_payload = ?, attempts = attempts + 1 WHERE uuid = ?`,
result.server ? JSON.stringify(result.server) : null,
result.uuid,
);
} else {
await db.runAsync(
`UPDATE outbox SET status = 'error', error = ?, attempts = attempts + 1 WHERE uuid = ?`,
result.error ?? 'unknown error',
result.uuid,
);
}
}
/** Limpia las operaciones ya confirmadas (housekeeping opcional). */
export async function purgeSentOperations(): Promise<void> {
const db = await getDb();
await db.runAsync(`DELETE FROM outbox WHERE status = 'sent'`);
}
export interface OutboxCounts {
pending: number;
conflict: number;
error: number;
mediaPending: number;
}
export async function getOutboxCounts(): Promise<OutboxCounts> {
const db = await getDb();
const r = await db.getFirstAsync<{ pending: number; conflict: number; error: number }>(
`SELECT
SUM(status='pending') AS pending,
SUM(status='conflict') AS conflict,
SUM(status='error') AS error
FROM outbox`,
);
const m = await db.getFirstAsync<{ n: number }>(
`SELECT COUNT(*) AS n FROM media_outbox WHERE status = 'pending'`,
);
return {
pending: r?.pending ?? 0,
conflict: r?.conflict ?? 0,
error: r?.error ?? 0,
mediaPending: m?.n ?? 0,
};
}
// ---------- media outbox ----------
export interface EnqueueMediaInput {
parentEntity: string;
parentId: number;
localUri: string;
fileName?: string;
mimeType?: string;
category?: 'image' | 'document' | 'other';
description?: string;
}
export async function enqueueMedia(input: EnqueueMediaInput): Promise<string> {
const db = await getDb();
const uuid = newUuid();
await db.runAsync(
`INSERT INTO media_outbox
(uuid, parent_entity, parent_id, local_uri, file_name, mime_type, category, description, status, attempts, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)`,
uuid,
input.parentEntity,
input.parentId,
input.localUri,
input.fileName ?? null,
input.mimeType ?? null,
input.category ?? null,
input.description ?? null,
nowIso(),
);
return uuid;
}
export interface MediaOutboxRow {
uuid: string;
parent_entity: string;
parent_id: number;
local_uri: string;
file_name: string | null;
mime_type: string | null;
category: 'image' | 'document' | 'other' | null;
description: string | null;
status: 'pending' | 'sent' | 'error';
media_id: number | null;
error: string | null;
attempts: number;
}
export async function getPendingMedia(limit = 50): Promise<MediaOutboxRow[]> {
const db = await getDb();
return db.getAllAsync<MediaOutboxRow>(
`SELECT * FROM media_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
limit,
);
}
export async function markMediaSent(uuid: string, mediaId: number | null): Promise<void> {
const db = await getDb();
await db.runAsync(
`UPDATE media_outbox SET status = 'sent', media_id = ?, error = NULL WHERE uuid = ?`,
mediaId,
uuid,
);
}
export async function markMediaError(uuid: string, error: string): Promise<void> {
const db = await getDb();
await db.runAsync(
`UPDATE media_outbox SET status = 'error', error = ?, attempts = attempts + 1 WHERE uuid = ?`,
error,
uuid,
);
}
+315
View File
@@ -0,0 +1,315 @@
/**
* Repositorios: persistencia del bundle (PULL) y operaciones de lectura para la UI.
* El manejo del outbox vive en outbox.ts.
*/
import type { SQLiteDatabase } from 'expo-sqlite';
import { getDb } from './database';
import {
Bundle,
DeletedTombstones,
Feature,
Issue,
IssueComment,
IssueTask,
Layer,
Phase,
Project,
Template,
} from '../api/types';
const CURSOR_PREFIX = 'cursor:';
const ACTIVE_PROJECT_KEY = 'active_project';
// ---------- meta ----------
export async function getMeta(key: string): Promise<string | null> {
const db = await getDb();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT value FROM meta WHERE key = ?',
key,
);
return row?.value ?? null;
}
export async function setMeta(key: string, value: string): Promise<void> {
const db = await getDb();
await db.runAsync(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
key,
value,
);
}
export const getCursor = (projectId: number) => getMeta(CURSOR_PREFIX + projectId);
export const setCursor = (projectId: number, serverTime: string) =>
setMeta(CURSOR_PREFIX + projectId, serverTime);
export const getActiveProjectId = async (): Promise<number | null> => {
const v = await getMeta(ACTIVE_PROJECT_KEY);
return v ? Number(v) : null;
};
export const setActiveProjectId = (id: number) => setMeta(ACTIVE_PROJECT_KEY, String(id));
// ---------- upsert genérico (preserva columnas locales) ----------
/**
* INSERT ... ON CONFLICT(id) DO UPDATE: sólo toca las columnas del servidor,
* así no pisa local_uuid/dirty de la fila.
*/
async function upsertById(
db: SQLiteDatabase,
table: string,
row: Record<string, unknown>,
): Promise<void> {
const cols = Object.keys(row);
const placeholders = cols.map(() => '?').join(', ');
const quoted = cols.map((c) => `"${c}"`).join(', ');
const updates = cols
.filter((c) => c !== 'id')
.map((c) => `"${c}" = excluded."${c}"`)
.join(', ');
const sql =
`INSERT INTO ${table} (${quoted}) VALUES (${placeholders}) ` +
`ON CONFLICT(id) DO UPDATE SET ${updates}`;
await db.runAsync(sql, ...cols.map((c) => row[c] as never));
}
const json = (v: unknown) => (v == null ? null : JSON.stringify(v));
// ---------- aplicar bundle ----------
/**
* Aplica un bundle (snapshot o delta) a la BD local en una transacción:
* upsert de cada entidad + borrado de tombstones + guardado del cursor.
*/
export async function applyBundle(bundle: Bundle): Promise<void> {
const db = await getDb();
const pid = bundle.project?.id;
await db.withTransactionAsync(async () => {
if (bundle.project) {
await upsertById(db, 'projects', pickProject(bundle.project));
}
for (const p of bundle.phases ?? []) {
await upsertById(db, 'phases', { ...pickPhase(p), project_id: pid });
}
for (const l of bundle.layers ?? []) {
await upsertById(db, 'layers', { ...pickLayer(l), project_id: pid });
}
for (const f of bundle.features ?? []) {
await upsertById(db, 'features', { ...pickFeature(f), project_id: pid });
}
for (const i of bundle.inspections ?? []) {
await upsertById(db, 'inspections', { ...pickInspection(i), project_id: pid });
}
for (const i of bundle.issues ?? []) {
await upsertById(db, 'issues', { ...pickIssue(i), project_id: pid });
}
for (const t of (bundle.issue_tasks ?? []) as IssueTask[]) {
await upsertById(db, 'issue_tasks', pickIssueTask(t));
}
for (const c of bundle.issue_comments ?? []) {
await upsertById(db, 'issue_comments', pickIssueComment(c));
}
for (const t of bundle.templates ?? []) {
await upsertById(db, 'templates', pickTemplate(t));
}
for (const m of bundle.media ?? []) {
await upsertById(db, 'media', {
id: m.id,
uuid: m.uuid,
parent_entity: m.parent_entity,
parent_id: m.parent_id,
url: m.url,
name: m.name ?? null,
file_type: m.file_type ?? null,
category: m.category ?? null,
updated_at: m.updated_at,
});
}
await applyTombstones(db, bundle.deleted);
if (pid != null && bundle.server_time) {
await db.runAsync(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
CURSOR_PREFIX + pid,
bundle.server_time,
);
}
});
}
async function applyTombstones(db: SQLiteDatabase, deleted?: DeletedTombstones): Promise<void> {
if (!deleted) return;
const map: Record<keyof DeletedTombstones, string> = {
phases: 'phases',
layers: 'layers',
features: 'features',
inspections: 'inspections',
issues: 'issues',
issue_tasks: 'issue_tasks',
issue_comments: 'issue_comments',
};
for (const key of Object.keys(map) as (keyof DeletedTombstones)[]) {
const ids = deleted[key];
if (ids && ids.length) {
const placeholders = ids.map(() => '?').join(', ');
await db.runAsync(`DELETE FROM ${map[key]} WHERE id IN (${placeholders})`, ...ids);
}
}
}
// ---------- proyección a columnas (sólo lo que persistimos) ----------
const pickProject = (p: Project) => ({
id: p.id,
reference: p.reference ?? null,
name: p.name,
address: p.address ?? null,
lat: p.lat ?? null,
lng: p.lng ?? null,
status: p.status ?? null,
updated_at: p.updated_at,
});
const pickPhase = (p: Phase) => ({
id: p.id,
name: p.name,
order: p.order ?? null,
color: p.color ?? null,
progress_percent: p.progress_percent ?? null,
updated_at: p.updated_at,
});
const pickLayer = (l: Layer) => ({
id: l.id,
phase_id: l.phase_id,
name: l.name,
color: l.color ?? null,
updated_at: l.updated_at,
});
const pickFeature = (f: Feature) => ({
id: f.id,
layer_id: f.layer_id,
name: f.name,
geometry: json(f.geometry),
status: f.status ?? null,
progress: f.progress ?? null,
responsible: f.responsible ?? null,
template_id: f.template_id ?? null,
updated_at: f.updated_at,
});
const pickInspection = (i: import('../api/types').Inspection) => ({
id: i.id,
feature_id: i.feature_id,
layer_id: i.layer_id ?? null,
template_id: i.template_id ?? null,
user_id: i.user_id ?? null,
data: json(i.data),
status: i.status ?? null,
result: i.result ?? null,
notes: i.notes ?? null,
created_at: i.created_at ?? null,
updated_at: i.updated_at,
});
const pickIssue = (i: Issue) => ({
id: i.id,
feature_id: i.feature_id ?? null,
title: i.title,
description: i.description ?? null,
status: i.status ?? null,
priority: i.priority ?? null,
type: i.type ?? null,
reported_by: i.reported_by ?? null,
assigned_to: i.assigned_to ?? null,
resolved_at: i.resolved_at ?? null,
updated_at: i.updated_at,
});
const pickIssueTask = (t: IssueTask) => ({
id: t.id,
issue_id: t.issue_id,
title: t.title,
is_done: t.is_done ? 1 : 0,
done_at: t.done_at ?? null,
done_by: t.done_by ?? null,
assigned_to: t.assigned_to ?? null,
due_date: t.due_date ?? null,
order: t.order ?? null,
updated_at: t.updated_at,
});
const pickIssueComment = (c: IssueComment) => ({
id: c.id,
issue_id: c.issue_id,
user_id: c.user_id ?? null,
body: c.body,
created_at: c.created_at ?? null,
updated_at: c.updated_at,
});
const pickTemplate = (t: Template) => ({
id: t.id,
project_id: t.project_id ?? null,
phase_id: t.phase_id ?? null,
name: t.name,
description: t.description ?? null,
fields: json(t.fields),
version: String(t.version),
hash: t.hash ?? null,
updated_at: t.updated_at,
});
// ---------- lecturas para la UI ----------
export async function getProjects(): Promise<Project[]> {
const db = await getDb();
return db.getAllAsync<Project>('SELECT * FROM projects ORDER BY name');
}
/** Persiste la lista que devuelve GET /projects (upsert sin tocar el resto). */
export async function saveProjectList(projects: Project[]): Promise<void> {
const db = await getDb();
await db.withTransactionAsync(async () => {
for (const p of projects) {
await upsertById(db, 'projects', pickProject(p));
}
});
}
export async function getPhases(projectId: number): Promise<Phase[]> {
const db = await getDb();
return db.getAllAsync<Phase>(
'SELECT * FROM phases WHERE project_id = ? ORDER BY "order", id',
projectId,
);
}
export async function getIssues(projectId: number): Promise<Issue[]> {
const db = await getDb();
return db.getAllAsync<Issue>(
'SELECT * FROM issues WHERE project_id = ? ORDER BY updated_at DESC',
projectId,
);
}
export async function countRows(table: string): Promise<number> {
const db = await getDb();
const row = await db.getFirstAsync<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
return row?.n ?? 0;
}
/** Aplica el valor del servidor (recibido en un conflicto) a la fila local. */
export async function applyServerValue(
table: string,
id: number,
serverValue: Record<string, unknown>,
): Promise<void> {
const db = await getDb();
await upsertById(db, table, { id, ...serverValue });
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Esquema de la BD local (SQLite). Refleja las entidades del bundle (PULL) y
* añade dos colas de salida (outbox de operaciones y de media) más una tabla
* `meta` para el cursor de sync por proyecto.
*
* Versionado simple por `user_version` de SQLite (ver database.ts).
*/
export const SCHEMA_VERSION = 1;
export const SCHEMA_SQL = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Clave/valor: cursor de sync (key 'cursor:<projectId>'), proyecto activo, etc.
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- ===== Entidades del bundle (espejo del servidor) =====
-- 'id' es el id del servidor. 'updated_at' alimenta el delta/last-write-wins.
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
reference TEXT,
name TEXT,
address TEXT,
lat REAL,
lng REAL,
status TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS phases (
id INTEGER PRIMARY KEY,
project_id INTEGER,
name TEXT,
"order" INTEGER,
color TEXT,
progress_percent REAL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS layers (
id INTEGER PRIMARY KEY,
project_id INTEGER,
phase_id INTEGER,
name TEXT,
color TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS features (
id INTEGER PRIMARY KEY,
project_id INTEGER,
layer_id INTEGER,
name TEXT,
geometry TEXT, -- GeoJSON serializado
status TEXT,
progress REAL,
responsible TEXT,
template_id INTEGER,
updated_at TEXT,
-- columnas locales para conciliar creaciones offline:
local_uuid TEXT, -- uuid de la operación que la creó (si nació offline)
dirty INTEGER DEFAULT 0 -- 1 = hay cambios locales sin confirmar
);
CREATE TABLE IF NOT EXISTS inspections (
id INTEGER PRIMARY KEY,
project_id INTEGER,
feature_id INTEGER,
layer_id INTEGER,
template_id INTEGER,
user_id INTEGER,
data TEXT, -- objeto serializado
status TEXT,
result TEXT,
notes TEXT,
created_at TEXT,
updated_at TEXT,
local_uuid TEXT,
dirty INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY,
project_id INTEGER,
feature_id INTEGER,
title TEXT,
description TEXT,
status TEXT,
priority TEXT,
type TEXT,
reported_by INTEGER,
assigned_to INTEGER,
resolution_notes TEXT,
resolved_at TEXT,
updated_at TEXT,
local_uuid TEXT,
dirty INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issue_tasks (
id INTEGER PRIMARY KEY,
issue_id INTEGER,
title TEXT,
is_done INTEGER,
done_at TEXT,
done_by INTEGER,
assigned_to INTEGER,
due_date TEXT,
"order" INTEGER,
updated_at TEXT,
local_uuid TEXT,
dirty INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issue_comments (
id INTEGER PRIMARY KEY,
issue_id INTEGER,
user_id INTEGER,
body TEXT,
created_at TEXT,
updated_at TEXT,
local_uuid TEXT
);
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY,
project_id INTEGER,
phase_id INTEGER,
name TEXT,
description TEXT,
fields TEXT, -- array serializado
version TEXT,
hash TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY,
uuid TEXT,
parent_entity TEXT,
parent_id INTEGER,
url TEXT,
name TEXT,
file_type TEXT,
category TEXT,
updated_at TEXT
);
-- ===== Colas de salida (outbox) =====
-- Operaciones de /sync. 'uuid' = clave de idempotencia (PK).
CREATE TABLE IF NOT EXISTS outbox (
uuid TEXT PRIMARY KEY,
entity TEXT NOT NULL,
op TEXT NOT NULL, -- create | update
data TEXT NOT NULL, -- JSON del payload 'data'
client_updated_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|sent|conflict|error
server_id INTEGER,
error TEXT,
server_payload TEXT, -- JSON del valor del servidor en conflicto
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Ficheros pendientes de subir a /media.
CREATE TABLE IF NOT EXISTS media_outbox (
uuid TEXT PRIMARY KEY,
parent_entity TEXT NOT NULL,
parent_id INTEGER NOT NULL,
local_uri TEXT NOT NULL,
file_name TEXT,
mime_type TEXT,
category TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending|sent|error
media_id INTEGER,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status);
CREATE INDEX IF NOT EXISTS idx_media_outbox_status ON media_outbox(status);
CREATE INDEX IF NOT EXISTS idx_phases_project ON phases(project_id);
CREATE INDEX IF NOT EXISTS idx_layers_phase ON layers(phase_id);
CREATE INDEX IF NOT EXISTS idx_features_layer ON features(layer_id);
CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id);
CREATE INDEX IF NOT EXISTS idx_issue_tasks_issue ON issue_tasks(issue_id);
CREATE INDEX IF NOT EXISTS idx_issue_comments_issue ON issue_comments(issue_id);
`;
+44
View File
@@ -0,0 +1,44 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
import { ActivityIndicator, View } from 'react-native';
import { useSession } from '../auth/session';
import { LoginScreen } from '../screens/LoginScreen';
import { ProjectDetailScreen } from '../screens/ProjectDetailScreen';
import { ProjectsScreen } from '../screens/ProjectsScreen';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
const { ready, token } = useSession();
if (!ready) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
if (!token) {
return <LoginScreen />;
}
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen
name="Projects"
component={ProjectsScreen}
options={{ title: 'Proyectos' }}
/>
<Stack.Screen
name="ProjectDetail"
component={ProjectDetailScreen}
options={({ route }) => ({ title: route.params.name })}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
+4
View File
@@ -0,0 +1,4 @@
export type RootStackParamList = {
Projects: undefined;
ProjectDetail: { projectId: number; name: string };
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Estado de conectividad. Hook reactivo + comprobación puntual.
*/
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';
export function useIsOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setOnline(Boolean(state.isConnected && state.isInternetReachable !== false));
});
return unsubscribe;
}, []);
return online;
}
export async function isOnline(): Promise<boolean> {
const state = await NetInfo.fetch();
return Boolean(state.isConnected && state.isInternetReachable !== false);
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { ApiError } from '../api/client';
import { useSession } from '../auth/session';
const DEVICE_NAME = `${Platform.OS}-avante`;
export function LoginScreen() {
const { signIn } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
setError(null);
setLoading(true);
try {
await signIn(email.trim(), password, DEVICE_NAME);
} catch (e) {
if (e instanceof ApiError && e.status === 422) {
setError('Credenciales inválidas.');
} else {
setError(e instanceof Error ? e.message : 'Error de conexión.');
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>Avante · Seguimiento de obra</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.button, (loading || !email || !password) && styles.buttonDisabled]}
onPress={onSubmit}
disabled={loading || !email || !password}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Entrar</Text>
)}
</TouchableOpacity>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
error: { color: '#b00020', textAlign: 'center' },
button: {
backgroundColor: '#1f6f43',
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
});
+125
View File
@@ -0,0 +1,125 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue, Phase } from '../api/types';
import { SyncStatusBar } from '../components/SyncStatusBar';
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
import { getCursor, getIssues, getPhases } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
export function ProjectDetailScreen({ route }: Props) {
const { projectId } = route.params;
const [phases, setPhases] = useState<Phase[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [cursor, setCursorState] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const refresh = useCallback(async () => {
const [ph, iss, c, cur] = await Promise.all([
getPhases(projectId),
getIssues(projectId),
getOutboxCounts(),
getCursor(projectId),
]);
setPhases(ph);
setIssues(iss);
setCounts(c);
setCursorState(cur);
}, [projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onSync = useCallback(async () => {
if (!(await isOnline())) {
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
return;
}
setSyncing(true);
try {
const report = await runSync(projectId);
await refresh();
Alert.alert(
'Sincronización completada',
`Enviadas: ${report.applied}/${report.pushed}\n` +
`Conflictos: ${report.conflicts} · Errores: ${report.errors}\n` +
`Fotos: ${report.mediaUploaded} (errores ${report.mediaErrors})`,
);
} catch (e) {
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
} finally {
setSyncing(false);
}
}, [projectId, refresh]);
return (
<View style={styles.container}>
<SyncStatusBar counts={counts} syncing={syncing} />
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.section}>Fases ({phases.length})</Text>
{phases.map((p) => (
<View key={p.id} style={styles.card}>
<Text style={styles.cardTitle}>{p.name}</Text>
<Text style={styles.cardMeta}>{Math.round(p.progress_percent ?? 0)}%</Text>
</View>
))}
<Text style={styles.section}>Incidencias ({issues.length})</Text>
{issues.map((i) => (
<View key={i.id} style={styles.card}>
<Text style={styles.cardTitle}>{i.title}</Text>
<Text style={styles.cardMeta}>
{i.priority ?? '—'} · {i.status ?? '—'}
</Text>
</View>
))}
<Text style={styles.cursor}>
Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
</Text>
</ScrollView>
<TouchableOpacity
style={[styles.syncBtn, syncing && styles.syncBtnDisabled]}
onPress={onSync}
disabled={syncing}
>
<Text style={styles.syncBtnText}>{syncing ? 'Sincronizando…' : 'Sincronizar'}</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
body: { padding: 16, gap: 8 },
section: { fontSize: 16, fontWeight: '700', marginTop: 12 },
card: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#f4f4f4',
borderRadius: 8,
padding: 12,
},
cardTitle: { fontSize: 15, flex: 1 },
cardMeta: { fontSize: 13, color: '#666', marginLeft: 8 },
cursor: { marginTop: 20, color: '#888', fontSize: 12, textAlign: 'center' },
syncBtn: {
backgroundColor: '#1f6f43',
margin: 16,
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
},
syncBtnDisabled: { opacity: 0.5 },
syncBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
});
+125
View File
@@ -0,0 +1,125 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import * as api from '../api/endpoints';
import { Project } from '../api/types';
import { useSession } from '../auth/session';
import { getCursor, getProjects, saveProjectList } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { initialPull, runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
function normalize(res: { data?: Project[] } | Project[]): Project[] {
return Array.isArray(res) ? res : res.data ?? [];
}
export function ProjectsScreen({ navigation }: Props) {
const { user, signOut } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [opening, setOpening] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
if (await isOnline()) {
const list = normalize(await api.listProjects());
await saveProjectList(list);
}
setProjects(await getProjects());
} catch {
setProjects(await getProjects()); // fallback offline
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openProject = useCallback(
async (p: Project) => {
setOpening(p.id);
try {
const online = await isOnline();
if (online) {
const cursor = await getCursor(p.id);
if (cursor) await runSync(p.id);
else await initialPull(p.id);
}
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
} finally {
setOpening(null);
}
},
[navigation],
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.hello}>Hola, {user?.name ?? ''}</Text>
<TouchableOpacity onPress={signOut}>
<Text style={styles.logout}>Salir</Text>
</TouchableOpacity>
</View>
<FlatList
data={projects}
keyExtractor={(p) => String(p.id)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={
loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>
}
renderItem={({ item }) => (
<TouchableOpacity style={styles.row} onPress={() => openProject(item)}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
{item.reference ? <Text style={styles.ref}>{item.reference}</Text> : null}
</View>
{opening === item.id ? (
<ActivityIndicator />
) : (
<Text style={styles.chevron}></Text>
)}
</TouchableOpacity>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
},
hello: { fontSize: 16, fontWeight: '600' },
logout: { color: '#b00020', fontWeight: '600' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd',
},
name: { fontSize: 16, fontWeight: '600' },
ref: { fontSize: 13, color: '#666', marginTop: 2 },
chevron: { fontSize: 24, color: '#999' },
empty: { textAlign: 'center', marginTop: 40, color: '#888' },
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Motor de sincronización offline-first.
*
* Orden de un ciclo completo (ver brief §8):
* 1. PUSH — vacía el outbox de operaciones contra POST /sync.
* 2. MEDIA — sube los ficheros pendientes a POST /media.
* 3. PULL — baja el delta del proyecto (bundle?since=cursor) y lo aplica.
*
* Idempotente: cada operación lleva su uuid, así reenviar la cola es seguro.
* Conflictos: last-write-wins en servidor; si responde `conflict`, aplicamos
* el valor del servidor a la BD local y dejamos la op marcada para revisión.
*/
import { getBundle, sync as syncApi, uploadMedia } from '../api/endpoints';
import { Operation, OperationResult, SyncEntity } from '../api/types';
import { applyBundle, applyServerValue, getCursor } from '../db/repositories';
import {
applyOperationResult,
getPendingMedia,
getPendingOperations,
markMediaError,
markMediaSent,
} from '../db/outbox';
const SYNC_BATCH = 100;
/** Entidades editables → tabla local (para volcar el valor del servidor en conflicto). */
const ENTITY_TABLE: Partial<Record<SyncEntity, string>> = {
feature: 'features',
issue: 'issues',
issue_task: 'issue_tasks',
inspection: 'inspections',
issue_comment: 'issue_comments',
};
export interface SyncReport {
pushed: number;
applied: number;
conflicts: number;
errors: number;
mediaUploaded: number;
mediaErrors: number;
pulled: boolean;
}
/** Vacía el outbox de operaciones contra /sync. */
async function pushOperations(report: SyncReport): Promise<void> {
// eslint-disable-next-line no-constant-condition
while (true) {
const ops = await getPendingOperations(SYNC_BATCH);
if (ops.length === 0) break;
const { results } = await syncApi(ops);
report.pushed += ops.length;
const byUuid = new Map<string, Operation>(ops.map((o) => [o.uuid, o]));
for (const result of results) {
await processResult(result, byUuid.get(result.uuid), report);
}
// Si nada quedó 'pending' que pudiéramos reintentar, salimos para no
// bucear infinitamente con conflictos/errores.
if (results.every((r) => r.status !== 'applied' && r.status !== 'duplicate')) {
break;
}
}
}
async function processResult(
result: OperationResult,
op: Operation | undefined,
report: SyncReport,
): Promise<void> {
await applyOperationResult(result);
if (result.status === 'applied' || result.status === 'duplicate') {
report.applied += 1;
} else if (result.status === 'conflict') {
report.conflicts += 1;
// Volcamos el valor del servidor a la BD local (last-write-wins servidor).
if (op && result.server) {
const table = ENTITY_TABLE[op.entity];
const id = (op.data as { id?: number }).id;
if (table && id != null) {
await applyServerValue(table, id, result.server);
}
}
} else {
report.errors += 1;
}
}
/** Sube los ficheros pendientes a /media. */
async function pushMedia(report: SyncReport): Promise<void> {
const pending = await getPendingMedia();
for (const m of pending) {
try {
const res = await uploadMedia({
uuid: m.uuid,
parentEntity: m.parent_entity as never,
parentId: m.parent_id,
fileUri: m.local_uri,
fileName: m.file_name ?? undefined,
mimeType: m.mime_type ?? undefined,
category: m.category ?? undefined,
description: m.description ?? undefined,
});
await markMediaSent(m.uuid, res.media?.id ?? null);
report.mediaUploaded += 1;
} catch (e) {
await markMediaError(m.uuid, e instanceof Error ? e.message : String(e));
report.mediaErrors += 1;
}
}
}
/** Baja el delta del proyecto y lo aplica. */
async function pull(projectId: number, report: SyncReport): Promise<void> {
const since = await getCursor(projectId);
const bundle = await getBundle(projectId, since ?? undefined);
await applyBundle(bundle);
report.pulled = true;
}
/** Ciclo completo de sincronización para un proyecto. */
export async function runSync(projectId: number): Promise<SyncReport> {
const report: SyncReport = {
pushed: 0,
applied: 0,
conflicts: 0,
errors: 0,
mediaUploaded: 0,
mediaErrors: 0,
pulled: false,
};
await pushOperations(report);
await pushMedia(report);
await pull(projectId, report);
return report;
}
/** Primera sincronización: snapshot completo (sin cursor). */
export async function initialPull(projectId: number): Promise<void> {
const bundle = await getBundle(projectId);
await applyBundle(bundle);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Mutaciones de alto nivel. Cada una:
* 1. (updates) escribe optimistamente en la BD local para reflejar el cambio ya,
* 2. encola la operación en el outbox para enviarla cuando haya red.
*
* El servidor SIEMPRE fija user_id/reported_by/project_id y valida permisos:
* el cliente nunca los envía (ver brief §5.1).
*/
import { getDb } from '../db/database';
import { enqueueOperation } from '../db/outbox';
import {
IssuePriority,
IssueStatus,
IssueType,
} from '../api/types';
// ----- progress (append-only en servidor) -----
export function recordProgressUpdate(input: {
phase_id: number;
progress: number;
comment?: string;
location?: { lat: number; lng: number };
}): Promise<string> {
return enqueueOperation('progress_update', 'create', { ...input });
}
// ----- feature.update (editable, last-write-wins) -----
export async function updateFeature(input: {
id: number;
status?: string;
progress?: number;
responsible?: string;
}): Promise<string> {
const db = await getDb();
const sets: string[] = [];
const params: unknown[] = [];
if (input.status !== undefined) {
sets.push('status = ?');
params.push(input.status);
}
if (input.progress !== undefined) {
sets.push('progress = ?');
params.push(input.progress);
}
if (input.responsible !== undefined) {
sets.push('responsible = ?');
params.push(input.responsible);
}
if (sets.length) {
params.push(input.id);
await db.runAsync(
`UPDATE features SET ${sets.join(', ')}, dirty = 1 WHERE id = ?`,
...(params as never[]),
);
}
return enqueueOperation('feature', 'update', { ...input });
}
// ----- inspection.create (append-only) -----
export function createInspection(input: {
feature_id: number;
template_id?: number;
data?: Record<string, unknown>;
status?: string;
result?: string;
notes?: string;
}): Promise<string> {
return enqueueOperation('inspection', 'create', { ...input });
}
// ----- issues -----
export function createIssue(input: {
project_id: number;
feature_id?: number;
title: string;
description?: string;
priority?: IssuePriority;
status?: IssueStatus;
type?: IssueType;
}): Promise<string> {
return enqueueOperation('issue', 'create', { ...input });
}
export async function updateIssue(input: {
id: number;
title?: string;
description?: string;
priority?: IssuePriority;
status?: IssueStatus;
type?: IssueType;
assigned_to?: number;
resolution_notes?: string;
}): Promise<string> {
const db = await getDb();
const cols: Record<string, unknown> = {
title: input.title,
description: input.description,
priority: input.priority,
status: input.status,
type: input.type,
assigned_to: input.assigned_to,
resolution_notes: input.resolution_notes,
};
const sets: string[] = [];
const params: unknown[] = [];
for (const [k, v] of Object.entries(cols)) {
if (v !== undefined) {
sets.push(`${k} = ?`);
params.push(v);
}
}
if (sets.length) {
params.push(input.id);
await db.runAsync(
`UPDATE issues SET ${sets.join(', ')}, dirty = 1 WHERE id = ?`,
...(params as never[]),
);
}
return enqueueOperation('issue', 'update', { ...input });
}
// ----- issue tasks -----
export function createIssueTask(input: {
issue_id: number;
title: string;
assigned_to?: number;
due_date?: string;
is_done?: boolean;
}): Promise<string> {
return enqueueOperation('issue_task', 'create', { ...input });
}
export async function updateIssueTask(input: {
id: number;
title?: string;
assigned_to?: number;
due_date?: string;
is_done?: boolean;
}): Promise<string> {
const db = await getDb();
if (input.is_done !== undefined) {
await db.runAsync(
'UPDATE issue_tasks SET is_done = ?, dirty = 1 WHERE id = ?',
input.is_done ? 1 : 0,
input.id,
);
}
return enqueueOperation('issue_task', 'update', { ...input });
}
// ----- issue comments (append-only) -----
export function createIssueComment(input: {
issue_id: number;
body: string;
}): Promise<string> {
return enqueueOperation('issue_comment', 'create', { ...input });
}
+11
View File
@@ -0,0 +1,11 @@
import * as Crypto from 'expo-crypto';
/** UUID v4 generado en el cliente: clave de idempotencia de cada operación. */
export function newUuid(): string {
return Crypto.randomUUID();
}
/** Marca de tiempo del dispositivo en ISO8601 con offset (client_updated_at). */
export function nowIso(): string {
return new Date().toISOString();
}