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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type RootStackParamList = {
|
||||
Projects: undefined;
|
||||
ProjectDetail: { projectId: number; name: string };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user