Support reading progress synchronization across platforms (#34)

This commit is contained in:
Huang Xin
2024-12-24 12:31:02 +01:00
committed by GitHub
parent d7ccbbcaa2
commit 9be9bc8a34
30 changed files with 1135 additions and 214 deletions
+209
View File
@@ -0,0 +1,209 @@
import { NextRequest, NextResponse } from 'next/server';
import { PostgrestError } from '@supabase/supabase-js';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { BookDataRecord } from '@/types/book';
import { transformBookConfigToDB } from '@/utils/transform';
import { transformBookNoteToDB } from '@/utils/transform';
import { transformBookToDB } from '@/utils/transform';
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
const transformsToDB = {
books: transformBookToDB,
book_notes: transformBookNoteToDB,
book_configs: transformBookConfigToDB,
};
const DBSyncTypeMap = {
books: 'books',
book_notes: 'notes',
book_configs: 'configs',
};
type TableName = keyof typeof transformsToDB;
type DBError = { table: TableName; error: PostgrestError };
const getUserAndToken = async (req: NextRequest) => {
const authHeader = req.headers.get('authorization');
if (!authHeader) return {};
const token = authHeader.replace('Bearer ', '');
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) return {};
return { user, token };
};
export async function GET(req: NextRequest) {
const { user, token } = await getUserAndToken(req);
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const supabase = createSupabaseClient(token);
const { searchParams } = new URL(req.url);
const sinceParam = searchParams.get('since');
const typeParam = searchParams.get('type') as SyncType | undefined;
const bookParam = searchParams.get('book');
if (!sinceParam) {
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
}
const since = new Date(Number(sinceParam));
if (isNaN(since.getTime())) {
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
}
const sinceIso = since.toISOString();
try {
const results: SyncResult = { books: [], configs: [], notes: [] };
const errors: Record<TableName, DBError | null> = {
books: null,
book_notes: null,
book_configs: null,
};
const queryTables = async (table: TableName) => {
let query = supabase.from(table).select('*').eq('user_id', user.id);
if (bookParam) {
query.eq('book_hash', bookParam);
}
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
console.log('Querying table:', table, 'since:', sinceIso);
const { data, error } = await query;
if (error) throw { table, error } as DBError;
results[DBSyncTypeMap[table] as SyncType] = data || [];
};
if (!typeParam || typeParam === 'books') {
await queryTables('books').catch((err) => (errors['books'] = err));
}
if (!typeParam || typeParam === 'configs') {
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
}
if (!typeParam || typeParam === 'notes') {
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
}
const dbErrors = Object.values(errors).filter((err) => err !== null);
if (dbErrors.length > 0) {
console.error('Errors occurred:', dbErrors);
const errorMsg = dbErrors
.map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`)
.join('; ');
return NextResponse.json({ error: errorMsg }, { status: 500 });
}
return NextResponse.json(results, { status: 200 });
} catch (error: unknown) {
console.error(error);
const errorMessage = (error as PostgrestError).message || 'Unknown error';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { user, token } = await getUserAndToken(req);
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const supabase = createSupabaseClient(token);
const body = await req.json();
const { books = [], configs = [], notes = [] } = body as SyncData;
const upsertRecords = async (
table: TableName,
primaryKeys: (keyof BookDataRecord)[],
records: BookDataRecord[],
) => {
const authoritativeRecords: BookDataRecord[] = [];
for (const rec of records) {
const dbRec = transformsToDB[table](rec, user.id);
rec.user_id = user.id;
rec.book_hash = dbRec.book_hash;
const matchConditions: Record<string, string | number> = { user_id: user.id };
for (const pk of primaryKeys) {
matchConditions[pk] = rec[pk]!;
}
const { data: serverData, error: fetchError } = await supabase
.from(table)
.select()
.match(matchConditions)
.single();
if (fetchError && fetchError.code !== 'PGRST116') {
return { error: fetchError.message };
}
if (!serverData) {
const { data: inserted, error: insertError } = await supabase
.from(table)
.insert(dbRec)
.select()
.single();
if (insertError) return { error: insertError.message };
authoritativeRecords.push(inserted);
} else {
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
const serverUpdatedAt = serverData.updated_at
? new Date(serverData.updated_at).getTime()
: 0;
const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0;
const serverDeletedAt = serverData.deleted_at
? new Date(serverData.deleted_at).getTime()
: 0;
const clientIsNewer =
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
if (clientIsNewer) {
const { data: updated, error: updateError } = await supabase
.from(table)
.update(dbRec)
.match(matchConditions)
.select()
.single();
console.log('Updating record:', updated);
if (updateError) return { error: updateError.message };
authoritativeRecords.push(updated);
} else {
authoritativeRecords.push(serverData);
}
}
}
return { data: authoritativeRecords };
};
try {
const [booksResult, configsResult, notesResult] = await Promise.all([
upsertRecords('books', ['book_hash'], books as BookDataRecord[]),
upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]),
upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]),
]);
if (booksResult?.error) throw new Error(booksResult.error);
if (configsResult?.error) throw new Error(configsResult.error);
if (notesResult?.error) throw new Error(notesResult.error);
return NextResponse.json(
{
books: booksResult?.data || [],
configs: configsResult?.data || [],
notes: notesResult?.data || [],
},
{ status: 200 },
);
} catch (error: unknown) {
console.error(error);
const errorMessage = (error as PostgrestError).message || 'Unknown error';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}