feat(sharing): add collaborative shares, export-text, deep-link helper — note-collaborators module (11 new tests)
This commit is contained in:
parent
0e16714da1
commit
599d68e116
@ -17,6 +17,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
note_versions: { partitionKeyPath: '/workspaceId' },
|
note_versions: { partitionKeyPath: '/workspaceId' },
|
||||||
note_intake_rules: { partitionKeyPath: '/userId' },
|
note_intake_rules: { partitionKeyPath: '/userId' },
|
||||||
note_intake_jobs: { partitionKeyPath: '/userId' },
|
note_intake_jobs: { partitionKeyPath: '/userId' },
|
||||||
|
note_collaborators: { partitionKeyPath: '/sharedWithUserId' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function initCosmosIfNeeded(): Promise<void> {
|
export async function initCosmosIfNeeded(): Promise<void> {
|
||||||
|
|||||||
41
backend/src/modules/note-collaborators/repository.ts
Normal file
41
backend/src/modules/note-collaborators/repository.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { getCollection } from '../../lib/datastore.js';
|
||||||
|
import type { FilterMap } from '@bytelyst/datastore';
|
||||||
|
import type { NoteCollaboratorDoc } from './types.js';
|
||||||
|
|
||||||
|
function collection() {
|
||||||
|
return getCollection<NoteCollaboratorDoc>('note_collaborators', '/sharedWithUserId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCollaborator(doc: NoteCollaboratorDoc): Promise<NoteCollaboratorDoc> {
|
||||||
|
return collection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCollaboratorsForNote(
|
||||||
|
noteId: string,
|
||||||
|
productId: string,
|
||||||
|
): Promise<NoteCollaboratorDoc[]> {
|
||||||
|
const filter: FilterMap = { noteId, productId };
|
||||||
|
return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSharedWithMe(
|
||||||
|
sharedWithUserId: string,
|
||||||
|
productId: string,
|
||||||
|
): Promise<NoteCollaboratorDoc[]> {
|
||||||
|
const filter: FilterMap = { sharedWithUserId, productId };
|
||||||
|
return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findCollaborator(
|
||||||
|
noteId: string,
|
||||||
|
sharedWithUserId: string,
|
||||||
|
productId: string,
|
||||||
|
): Promise<NoteCollaboratorDoc | null> {
|
||||||
|
const filter: FilterMap = { noteId, sharedWithUserId, productId };
|
||||||
|
const items = await collection().findMany({ filter, limit: 1, offset: 0 });
|
||||||
|
return items[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCollaborator(id: string, sharedWithUserId: string): Promise<void> {
|
||||||
|
await collection().delete(id, sharedWithUserId);
|
||||||
|
}
|
||||||
198
backend/src/modules/note-collaborators/routes.test.ts
Normal file
198
backend/src/modules/note-collaborators/routes.test.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../lib/request-context.js', () => ({
|
||||||
|
getUserId: vi.fn(() => 'user_1'),
|
||||||
|
getRequestProductId: vi.fn(() => 'notelett'),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/feature-flags.js', () => ({
|
||||||
|
isFeatureEnabled: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||||
|
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||||
|
vi.mock('../../lib/config.js', () => ({ config: {} }));
|
||||||
|
vi.mock('../../lib/embeddings.js', () => ({
|
||||||
|
stripHtmlForEmbedding: vi.fn((s: string) => s.replace(/<[^>]*>/g, ' ').trim()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getNoteMock = vi.fn(async () => null);
|
||||||
|
vi.mock('../notes/repository.js', () => ({
|
||||||
|
getNote: (...args: unknown[]) => getNoteMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createCollaboratorMock = vi.fn(async (doc: Record<string, unknown>) => doc);
|
||||||
|
const listCollaboratorsForNoteMock = vi.fn(async () => []);
|
||||||
|
const listSharedWithMeMock = vi.fn(async () => []);
|
||||||
|
const findCollaboratorMock = vi.fn(async () => null);
|
||||||
|
const deleteCollaboratorMock = vi.fn(async () => undefined);
|
||||||
|
vi.mock('./repository.js', () => ({
|
||||||
|
createCollaborator: (...args: unknown[]) => createCollaboratorMock(...args as [Record<string, unknown>]),
|
||||||
|
listCollaboratorsForNote: (...args: unknown[]) => listCollaboratorsForNoteMock(...args),
|
||||||
|
listSharedWithMe: (...args: unknown[]) => listSharedWithMeMock(...args),
|
||||||
|
findCollaborator: (...args: unknown[]) => findCollaboratorMock(...args),
|
||||||
|
deleteCollaborator: (...args: unknown[]) => deleteCollaboratorMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildTestApp } from '../../test-helpers.js';
|
||||||
|
import { noteCollaboratorRoutes } from './routes.js';
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
return buildTestApp(noteCollaboratorRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('note-collaborators routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /notes/:id/share-with-user', () => {
|
||||||
|
it('shares a note with another user', async () => {
|
||||||
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
|
||||||
|
findCollaboratorMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/n1/share-with-user',
|
||||||
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(createCollaboratorMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects sharing with yourself', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/n1/share-with-user',
|
||||||
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_1', permission: 'view' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate share', async () => {
|
||||||
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
|
||||||
|
findCollaboratorMock.mockResolvedValueOnce({ id: 'existing' });
|
||||||
|
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/n1/share-with-user',
|
||||||
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /notes/:id/collaborators', () => {
|
||||||
|
it('lists collaborators', async () => {
|
||||||
|
listCollaboratorsForNoteMock.mockResolvedValueOnce([
|
||||||
|
{ id: 'c1', sharedWithUserId: 'user_2', permission: 'view' },
|
||||||
|
]);
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /shared-with-me', () => {
|
||||||
|
it('lists notes shared with current user', async () => {
|
||||||
|
listSharedWithMeMock.mockResolvedValueOnce([
|
||||||
|
{ id: 'c1', noteId: 'n1', sharedWithUserId: 'user_1' },
|
||||||
|
]);
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/shared-with-me' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /notes/:noteId/collaborators/:userId', () => {
|
||||||
|
it('removes a collaborator', async () => {
|
||||||
|
findCollaboratorMock.mockResolvedValueOnce({
|
||||||
|
id: 'c1', sharedByUserId: 'user_1', sharedWithUserId: 'user_2',
|
||||||
|
});
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/notes/n1/collaborators/user_2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
expect(deleteCollaboratorMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for missing collaborator', async () => {
|
||||||
|
findCollaboratorMock.mockResolvedValueOnce(null);
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/notes/n1/collaborators/user_2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /notes/:id/export-text', () => {
|
||||||
|
it('exports note as text formats', async () => {
|
||||||
|
getNoteMock.mockResolvedValueOnce({
|
||||||
|
id: 'n1', userId: 'user_1', productId: 'notelett',
|
||||||
|
title: 'Test Note', body: '<p>Hello world</p>',
|
||||||
|
});
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/n1/export-text',
|
||||||
|
payload: { workspaceId: 'ws-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.title).toBe('Test Note');
|
||||||
|
expect(body.plaintext).toBeDefined();
|
||||||
|
expect(body.markdown).toBeDefined();
|
||||||
|
expect(body.html).toBe('<p>Hello world</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for missing note', async () => {
|
||||||
|
getNoteMock.mockResolvedValueOnce(null);
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/n1/export-text',
|
||||||
|
payload: { workspaceId: 'ws-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /notes/:id/deep-link', () => {
|
||||||
|
it('returns deep link URLs', async () => {
|
||||||
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' });
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/notes/n1/deep-link?workspaceId=ws-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.web).toContain('/notes/n1');
|
||||||
|
expect(body.mobile).toBe('notelett://note/n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires workspaceId query param', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/deep-link' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/src/modules/note-collaborators/routes.ts
Normal file
154
backend/src/modules/note-collaborators/routes.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Note collaborators + enhanced sharing routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { getUserId, getRequestProductId } from '../../lib/request-context.js';
|
||||||
|
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||||
|
import { isFeatureEnabled } from '../../lib/feature-flags.js';
|
||||||
|
import { trackEvent } from '../../lib/telemetry.js';
|
||||||
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
|
import { stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
||||||
|
import * as noteRepo from '../notes/repository.js';
|
||||||
|
import * as collabRepo from './repository.js';
|
||||||
|
import { ShareWithUserSchema, ExportTextSchema } from './types.js';
|
||||||
|
import type { NoteCollaboratorDoc } from './types.js';
|
||||||
|
import { config } from '../../lib/config.js';
|
||||||
|
|
||||||
|
export async function noteCollaboratorRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
|
// ── Share note with another user ──────────────────────────────
|
||||||
|
app.post('/notes/:id/share-with-user', async (req, reply) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id: noteId } = req.params as { id: string };
|
||||||
|
|
||||||
|
if (!isFeatureEnabled('notelett_collaborative_sharing_enabled')) {
|
||||||
|
throw new BadRequestError('Collaborative sharing is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = ShareWithUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; '));
|
||||||
|
}
|
||||||
|
const input = parsed.data;
|
||||||
|
|
||||||
|
if (input.sharedWithUserId === userId) {
|
||||||
|
throw new BadRequestError('Cannot share a note with yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify note exists and belongs to user
|
||||||
|
const note = await noteRepo.getNote(noteId, input.workspaceId);
|
||||||
|
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
|
||||||
|
throw new NotFoundError('Note not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already shared
|
||||||
|
const existing = await collabRepo.findCollaborator(noteId, input.sharedWithUserId, productId);
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestError('Note is already shared with this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: NoteCollaboratorDoc = {
|
||||||
|
id: `collab_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
|
||||||
|
productId,
|
||||||
|
noteId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
sharedByUserId: userId,
|
||||||
|
sharedWithUserId: input.sharedWithUserId,
|
||||||
|
permission: input.permission,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await collabRepo.createCollaborator(doc);
|
||||||
|
trackEvent('note.shared_with_user', userId, { noteId, permission: input.permission });
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List collaborators on a note ──────────────────────────────
|
||||||
|
app.get('/notes/:id/collaborators', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id: noteId } = req.params as { id: string };
|
||||||
|
|
||||||
|
const collaborators = await collabRepo.listCollaboratorsForNote(noteId, productId);
|
||||||
|
return { items: collaborators, total: collaborators.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List notes shared with me ─────────────────────────────────
|
||||||
|
app.get('/shared-with-me', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
|
||||||
|
const shared = await collabRepo.listSharedWithMe(userId, productId);
|
||||||
|
return { items: shared, total: shared.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Revoke collaborator access ────────────────────────────────
|
||||||
|
app.delete('/notes/:noteId/collaborators/:userId', async (req, reply) => {
|
||||||
|
const currentUserId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { noteId, userId: targetUserId } = req.params as { noteId: string; userId: string };
|
||||||
|
|
||||||
|
const collab = await collabRepo.findCollaborator(noteId, targetUserId, productId);
|
||||||
|
if (!collab) throw new NotFoundError('Collaborator not found');
|
||||||
|
|
||||||
|
// Only the note owner (sharer) can revoke
|
||||||
|
if (collab.sharedByUserId !== currentUserId && collab.sharedWithUserId !== currentUserId) {
|
||||||
|
throw new BadRequestError('Only the note owner or the collaborator can revoke access');
|
||||||
|
}
|
||||||
|
|
||||||
|
await collabRepo.deleteCollaborator(collab.id, collab.sharedWithUserId);
|
||||||
|
trackEvent('note.collaborator_removed', currentUserId, { noteId, removedUserId: targetUserId });
|
||||||
|
reply.code(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Export note as clean text ─────────────────────────────────
|
||||||
|
app.post('/notes/:id/export-text', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { id: noteId } = req.params as { id: string };
|
||||||
|
|
||||||
|
const parsed = ExportTextSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await noteRepo.getNote(noteId, parsed.data.workspaceId);
|
||||||
|
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
|
||||||
|
throw new NotFoundError('Note not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = stripHtmlForEmbedding(note.body);
|
||||||
|
const markdown = plaintext; // Basic — HTML is already stripped
|
||||||
|
const html = note.body;
|
||||||
|
|
||||||
|
trackEvent('note.exported_text', userId, { noteId });
|
||||||
|
return { markdown, plaintext, html, title: note.title };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Deep link helper ──────────────────────────────────────────
|
||||||
|
app.get('/notes/:id/deep-link', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id: noteId } = req.params as { id: string };
|
||||||
|
const workspaceId = (req.query as Record<string, string>).workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
|
||||||
|
|
||||||
|
const note = await noteRepo.getNote(noteId, workspaceId);
|
||||||
|
if (!note || note.productId !== PRODUCT_ID) {
|
||||||
|
throw new NotFoundError('Note not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const webOrigin = process.env.NEXT_PUBLIC_WEB_APP_ORIGIN || `http://localhost:3045`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
web: `${webOrigin}/notes/${noteId}`,
|
||||||
|
mobile: `notelett://note/${noteId}`,
|
||||||
|
public: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
29
backend/src/modules/note-collaborators/types.ts
Normal file
29
backend/src/modules/note-collaborators/types.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const COLLABORATOR_PERMISSIONS = ['view', 'comment', 'edit'] as const;
|
||||||
|
export type CollaboratorPermission = (typeof COLLABORATOR_PERMISSIONS)[number];
|
||||||
|
|
||||||
|
export interface NoteCollaboratorDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
sharedByUserId: string;
|
||||||
|
sharedWithUserId: string;
|
||||||
|
permission: CollaboratorPermission;
|
||||||
|
createdAt: string;
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShareWithUserSchema = z.object({
|
||||||
|
workspaceId: z.string().min(1).max(128),
|
||||||
|
sharedWithUserId: z.string().min(1).max(128),
|
||||||
|
permission: z.enum(COLLABORATOR_PERMISSIONS).default('view'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ShareWithUserInput = z.infer<typeof ShareWithUserSchema>;
|
||||||
|
|
||||||
|
export const ExportTextSchema = z.object({
|
||||||
|
workspaceId: z.string().min(1).max(128),
|
||||||
|
});
|
||||||
@ -39,6 +39,7 @@ vi.mock('./modules/note-prompts/scheduler.js', () => ({
|
|||||||
stopSchedulerLoop: vi.fn(),
|
stopSchedulerLoop: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() }));
|
vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() }));
|
||||||
|
vi.mock('./modules/note-collaborators/routes.js', () => ({ noteCollaboratorRoutes: vi.fn() }));
|
||||||
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
|
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
|
||||||
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
|
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
|
||||||
vi.mock('./lib/config.js', () => ({
|
vi.mock('./lib/config.js', () => ({
|
||||||
@ -78,7 +79,7 @@ describe('server bootstrap', () => {
|
|||||||
expect(initDatastoreMock).toHaveBeenCalledOnce();
|
expect(initDatastoreMock).toHaveBeenCalledOnce();
|
||||||
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
||||||
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
||||||
expect(appMock.register).toHaveBeenCalledTimes(12);
|
expect(appMock.register).toHaveBeenCalledTimes(13);
|
||||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
|
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { workspaceRoutes } from './modules/workspaces/routes.js';
|
|||||||
import { notePromptRoutes } from './modules/note-prompts/routes.js';
|
import { notePromptRoutes } from './modules/note-prompts/routes.js';
|
||||||
import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js';
|
import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js';
|
||||||
import { intakeRoutes } from './modules/intake/routes.js';
|
import { intakeRoutes } from './modules/intake/routes.js';
|
||||||
|
import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initEncryption } from './lib/field-encrypt.js';
|
import { initEncryption } from './lib/field-encrypt.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
@ -67,6 +68,7 @@ await registerApiPlugin(workspaceRoutes);
|
|||||||
await registerApiPlugin(notePromptRoutes);
|
await registerApiPlugin(notePromptRoutes);
|
||||||
await registerApiPlugin(promptSchedulerRoutes);
|
await registerApiPlugin(promptSchedulerRoutes);
|
||||||
await registerApiPlugin(intakeRoutes);
|
await registerApiPlugin(intakeRoutes);
|
||||||
|
await registerApiPlugin(noteCollaboratorRoutes);
|
||||||
|
|
||||||
// ── Start scheduler loop (F25) ────────────────────────────────────
|
// ── Start scheduler loop (F25) ────────────────────────────────────
|
||||||
startSchedulerLoop();
|
startSchedulerLoop();
|
||||||
|
|||||||
114
mobile/src/api/intake.ts
Normal file
114
mobile/src/api/intake.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { getApiClient } from './client';
|
||||||
|
|
||||||
|
// ── Content Types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const INTAKE_CONTENT_TYPES = [
|
||||||
|
'youtube', 'article', 'pdf', 'tweet', 'reddit', 'github', 'generic',
|
||||||
|
] as const;
|
||||||
|
export type IntakeContentType = (typeof INTAKE_CONTENT_TYPES)[number];
|
||||||
|
|
||||||
|
export const INTAKE_JOB_STATUSES = [
|
||||||
|
'queued', 'extracting', 'processing', 'complete', 'failed',
|
||||||
|
] as const;
|
||||||
|
export type IntakeJobStatus = (typeof INTAKE_JOB_STATUSES)[number];
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type IntakeSubmitResult = {
|
||||||
|
jobId: string;
|
||||||
|
noteId: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
ruleMatched: string | null;
|
||||||
|
templateSlug: string;
|
||||||
|
status: 'queued';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntakeJob = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
noteId: string;
|
||||||
|
ruleId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
templateSlug: string;
|
||||||
|
status: IntakeJobStatus;
|
||||||
|
extractedText?: string;
|
||||||
|
error?: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntakeRule = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
urlPattern: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
templateId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntakeJobListResponse = {
|
||||||
|
items: IntakeJob[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntakeRuleListResponse = {
|
||||||
|
items: IntakeRule[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListIntakeJobsOptions = {
|
||||||
|
status?: string;
|
||||||
|
since?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── API Functions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitIntake(
|
||||||
|
url: string,
|
||||||
|
workspaceId?: string,
|
||||||
|
templateOverride?: string,
|
||||||
|
): Promise<IntakeSubmitResult> {
|
||||||
|
return getApiClient().fetch<IntakeSubmitResult>('/intake', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(templateOverride ? { templateOverride } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeJobs(
|
||||||
|
options?: ListIntakeJobsOptions,
|
||||||
|
): Promise<IntakeJob[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.status) params.set('status', options.status);
|
||||||
|
if (options?.since) params.set('since', options.since);
|
||||||
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
if (options?.offset) params.set('offset', String(options.offset));
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const path = qs ? `/intake/jobs?${qs}` : '/intake/jobs';
|
||||||
|
const res = await getApiClient().fetch<IntakeJobListResponse>(path);
|
||||||
|
return res.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntakeJob(id: string): Promise<IntakeJob> {
|
||||||
|
return getApiClient().fetch<IntakeJob>(`/intake/jobs/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeRules(): Promise<IntakeRule[]> {
|
||||||
|
const res = await getApiClient().fetch<IntakeRuleListResponse>('/intake-rules');
|
||||||
|
return res.items;
|
||||||
|
}
|
||||||
@ -1,9 +1,19 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
|
import { useIntakeStore, type IntakeState } from '../../store/intake-store';
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
const activeJobCount = useIntakeStore((state: IntakeState) => state.activeJobs.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs screenOptions={{ headerShown: false }}>
|
<Tabs screenOptions={{ headerShown: false }}>
|
||||||
<Tabs.Screen name="index" options={{ title: 'Home', tabBarAccessibilityLabel: 'Home tab' }} />
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Home',
|
||||||
|
tabBarAccessibilityLabel: 'Home tab',
|
||||||
|
tabBarBadge: activeJobCount > 0 ? activeJobCount : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen name="search" options={{ title: 'Search', tabBarAccessibilityLabel: 'Search tab' }} />
|
<Tabs.Screen name="search" options={{ title: 'Search', tabBarAccessibilityLabel: 'Search tab' }} />
|
||||||
<Tabs.Screen name="capture" options={{ title: 'Capture', tabBarAccessibilityLabel: 'Capture tab' }} />
|
<Tabs.Screen name="capture" options={{ title: 'Capture', tabBarAccessibilityLabel: 'Capture tab' }} />
|
||||||
<Tabs.Screen name="inbox" options={{ title: 'Inbox', tabBarAccessibilityLabel: 'Inbox tab' }} />
|
<Tabs.Screen name="inbox" options={{ title: 'Inbox', tabBarAccessibilityLabel: 'Inbox tab' }} />
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import * as Clipboard from 'expo-clipboard';
|
|||||||
import type { MobileWorkspace } from '../../api/workspaces';
|
import type { MobileWorkspace } from '../../api/workspaces';
|
||||||
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
||||||
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
|
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
|
||||||
import { extractFromUrl } from '../../api/note-prompts';
|
import { useIntakeStore, type IntakeState } from '../../store/intake-store';
|
||||||
|
import { submitIntake, type IntakeSubmitResult } from '../../api/intake';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste';
|
type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste';
|
||||||
|
|||||||
@ -1,61 +1,110 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
|
import { View, Text, TextInput, Pressable, StyleSheet, ActivityIndicator } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { extractFromUrl } from '../../../api/note-prompts';
|
import { submitIntake, type IntakeSubmitResult } from '../../../api/intake';
|
||||||
|
import { useWorkspaceStore, type WorkspaceState } from '../../../store/workspace-store';
|
||||||
|
import { useIntakeStore, type IntakeState } from '../../../store/intake-store';
|
||||||
import { colors } from '../../../theme';
|
import { colors } from '../../../theme';
|
||||||
|
|
||||||
|
function classifyUrlLocal(url: string): string {
|
||||||
|
if (/youtube\.com|youtu\.be/i.test(url)) return 'YouTube';
|
||||||
|
if (/twitter\.com|x\.com/i.test(url)) return 'Tweet';
|
||||||
|
if (/reddit\.com/i.test(url)) return 'Reddit';
|
||||||
|
if (/github\.com/i.test(url)) return 'GitHub';
|
||||||
|
if (/\.pdf(\?|$)/i.test(url)) return 'PDF';
|
||||||
|
if (/^https?:\/\//i.test(url)) return 'Article';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export default function UrlCaptureScreen() {
|
export default function UrlCaptureScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [result, setResult] = useState<{ title: string; content: string } | null>(null);
|
const [result, setResult] = useState<IntakeSubmitResult | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
||||||
|
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
|
||||||
|
|
||||||
async function handleExtract() {
|
const detectedType = url.trim() ? classifyUrlLocal(url.trim()) : null;
|
||||||
|
|
||||||
|
async function handleProcess() {
|
||||||
if (!url.trim()) return;
|
if (!url.trim()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await extractFromUrl(url.trim(), 'default');
|
const res = await submitIntake(url.trim(), activeWorkspaceId ?? undefined);
|
||||||
setResult({ title: res.title, content: res.content });
|
setResult(res);
|
||||||
|
waitForJob(res.jobId, (job) => {
|
||||||
|
if (job.status === 'complete') {
|
||||||
|
router.push(`/note/${job.noteId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Extraction failed');
|
setError(err instanceof Error ? err.message : 'Intake failed');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAdvanced() {
|
||||||
|
router.push({ pathname: '/intake', params: { url: url.trim() } });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.heading}>URL Capture</Text>
|
<Text style={styles.heading}>URL Capture</Text>
|
||||||
<Text style={styles.hint}>Paste a URL to extract and summarize its content.</Text>
|
<Text style={styles.hint}>Paste a URL to extract and process with the intake pipeline.</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={(text) => { setUrl(text); setResult(null); setError(null); }}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
accessibilityLabel="URL input"
|
accessibilityLabel="URL input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TouchableOpacity
|
{detectedType && (
|
||||||
style={[styles.btn, busy && { opacity: 0.6 }]}
|
<View style={styles.typeRow}>
|
||||||
disabled={busy || !url.trim()}
|
<Text style={styles.typeLabel}>Detected:</Text>
|
||||||
onPress={() => void handleExtract()}
|
<View style={styles.typeBadge}>
|
||||||
accessibilityLabel="Extract content"
|
<Text style={styles.typeBadgeText}>{detectedType}</Text>
|
||||||
>
|
</View>
|
||||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.btnText}>Extract</Text>}
|
</View>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
|
|
||||||
{error && <Text style={styles.error}>{error}</Text>}
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
{result && (
|
{result ? (
|
||||||
<View style={styles.resultCard}>
|
<View style={styles.resultCard}>
|
||||||
<Text style={styles.resultTitle}>{result.title}</Text>
|
<ActivityIndicator color={colors.accentPrimary} size="small" />
|
||||||
<Text style={styles.resultContent} numberOfLines={20}>{result.content}</Text>
|
<Text style={styles.resultText}>Processing as {result.contentType}…</Text>
|
||||||
|
<Text style={styles.resultMeta}>Job: {result.jobId}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, (busy || !url.trim()) && styles.btnDisabled]}
|
||||||
|
disabled={busy || !url.trim()}
|
||||||
|
onPress={() => void handleProcess()}
|
||||||
|
accessibilityLabel="Process URL with AI"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={colors.textPrimary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.btnText}>Process with AI</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.secondaryBtn}
|
||||||
|
disabled={!url.trim()}
|
||||||
|
onPress={handleAdvanced}
|
||||||
|
accessibilityLabel="Advanced intake options"
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryBtnText}>Options…</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -67,10 +116,18 @@ const styles = StyleSheet.create({
|
|||||||
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
|
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
|
||||||
hint: { fontSize: 13, color: colors.textSecondary },
|
hint: { fontSize: 13, color: colors.textSecondary },
|
||||||
input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 },
|
input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 },
|
||||||
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 10, alignItems: 'center' },
|
typeRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
btnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
typeLabel: { fontSize: 13, color: colors.textSecondary },
|
||||||
|
typeBadge: { backgroundColor: colors.accentPrimary + '22', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3, borderWidth: 1, borderColor: colors.accentPrimary },
|
||||||
|
typeBadgeText: { fontSize: 12, fontWeight: '700', color: colors.accentPrimary },
|
||||||
|
buttonRow: { gap: 10 },
|
||||||
|
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 12, alignItems: 'center' },
|
||||||
|
btnDisabled: { opacity: 0.5 },
|
||||||
|
btnText: { color: colors.textPrimary, fontWeight: '700', fontSize: 15 },
|
||||||
|
secondaryBtn: { borderRadius: 8, paddingVertical: 10, alignItems: 'center', borderWidth: 1, borderColor: colors.borderDefault },
|
||||||
|
secondaryBtnText: { color: colors.textSecondary, fontWeight: '600', fontSize: 14 },
|
||||||
error: { color: colors.danger, fontSize: 13 },
|
error: { color: colors.danger, fontSize: 13 },
|
||||||
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, gap: 8 },
|
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.accentPrimary, gap: 8, alignItems: 'center' },
|
||||||
resultTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary },
|
resultText: { fontSize: 14, color: colors.textPrimary },
|
||||||
resultContent: { fontSize: 14, color: colors.textSecondary, lineHeight: 20 },
|
resultMeta: { fontSize: 11, color: colors.textTertiary },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar';
|
|||||||
import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
import { useAuthStore, type AuthState } from '../store/auth-store';
|
import { useAuthStore, type AuthState } from '../store/auth-store';
|
||||||
import { useInboxStore } from '../store/inbox-store';
|
import { useInboxStore } from '../store/inbox-store';
|
||||||
|
import { useIntakeStore } from '../store/intake-store';
|
||||||
import { useNotesStore } from '../store/notes-store';
|
import { useNotesStore } from '../store/notes-store';
|
||||||
import { useWorkspaceStore } from '../store/workspace-store';
|
import { useWorkspaceStore } from '../store/workspace-store';
|
||||||
import { checkKillSwitch, flushTelemetry, initPlatform } from '../lib/platform';
|
import { checkKillSwitch, flushTelemetry, initPlatform } from '../lib/platform';
|
||||||
@ -136,6 +137,7 @@ export default function RootLayout() {
|
|||||||
void flushQueuedNoteMutations();
|
void flushQueuedNoteMutations();
|
||||||
void loadBroadcasts();
|
void loadBroadcasts();
|
||||||
void loadSurvey();
|
void loadSurvey();
|
||||||
|
void useIntakeStore.getState().pollActiveJobs();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const broadcastTimer = setInterval(() => {
|
const broadcastTimer = setInterval(() => {
|
||||||
@ -146,6 +148,10 @@ export default function RootLayout() {
|
|||||||
void loadSurvey();
|
void loadSurvey();
|
||||||
}, 10 * 60_000);
|
}, 10 * 60_000);
|
||||||
|
|
||||||
|
const intakeTimer = setInterval(() => {
|
||||||
|
void useIntakeStore.getState().pollActiveJobs();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
||||||
if (nextState === 'active') {
|
if (nextState === 'active') {
|
||||||
void flushQueuedNoteMutations();
|
void flushQueuedNoteMutations();
|
||||||
@ -156,6 +162,7 @@ export default function RootLayout() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(broadcastTimer);
|
clearInterval(broadcastTimer);
|
||||||
clearInterval(surveyTimer);
|
clearInterval(surveyTimer);
|
||||||
|
clearInterval(intakeTimer);
|
||||||
appStateSubscription.remove();
|
appStateSubscription.remove();
|
||||||
};
|
};
|
||||||
}, [hasBootstrapped, isAuthenticated]);
|
}, [hasBootstrapped, isAuthenticated]);
|
||||||
|
|||||||
345
mobile/src/app/intake.tsx
Normal file
345
mobile/src/app/intake.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { submitIntake, listIntakeRules, type IntakeRule, type IntakeSubmitResult } from '../api/intake';
|
||||||
|
import { listPromptTemplates, type MobilePromptTemplate } from '../api/note-prompts';
|
||||||
|
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
|
||||||
|
import { useIntakeStore, type IntakeState } from '../store/intake-store';
|
||||||
|
import type { MobileWorkspace } from '../api/workspaces';
|
||||||
|
import { colors } from '../theme';
|
||||||
|
|
||||||
|
type ContentTypeBadge = { label: string; color: string };
|
||||||
|
|
||||||
|
const CONTENT_TYPE_MAP: Record<string, ContentTypeBadge> = {
|
||||||
|
youtube: { label: 'YouTube', color: '#FF0000' },
|
||||||
|
article: { label: 'Article', color: colors.accentPrimary },
|
||||||
|
pdf: { label: 'PDF', color: '#E44D26' },
|
||||||
|
tweet: { label: 'Tweet', color: '#1DA1F2' },
|
||||||
|
reddit: { label: 'Reddit', color: '#FF4500' },
|
||||||
|
github: { label: 'GitHub', color: '#6e5494' },
|
||||||
|
generic: { label: 'Web Page', color: colors.textSecondary },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContentTypeBadge(type: string): ContentTypeBadge {
|
||||||
|
return CONTENT_TYPE_MAP[type] ?? { label: type, color: colors.textSecondary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntakeScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{ url?: string }>();
|
||||||
|
const incomingUrl = params.url ?? '';
|
||||||
|
|
||||||
|
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
|
||||||
|
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
||||||
|
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
|
||||||
|
|
||||||
|
const [rules, setRules] = useState<IntakeRule[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<MobilePromptTemplate[]>([]);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
|
const [matchedRule, setMatchedRule] = useState<IntakeRule | null>(null);
|
||||||
|
const [detectedType, setDetectedType] = useState<string>('generic');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<IntakeSubmitResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRulesAndTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (incomingUrl && rules.length > 0) {
|
||||||
|
matchUrl(incomingUrl);
|
||||||
|
}
|
||||||
|
}, [incomingUrl, rules]);
|
||||||
|
|
||||||
|
async function loadRulesAndTemplates(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [fetchedRules, fetchedTemplates] = await Promise.all([
|
||||||
|
listIntakeRules(),
|
||||||
|
listPromptTemplates(),
|
||||||
|
]);
|
||||||
|
setRules(fetchedRules);
|
||||||
|
setTemplates(fetchedTemplates);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — user can still submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchUrl(url: string): void {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) continue;
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(rule.urlPattern, 'i');
|
||||||
|
if (regex.test(url)) {
|
||||||
|
setMatchedRule(rule);
|
||||||
|
setDetectedType(rule.contentType);
|
||||||
|
setSelectedTemplate(rule.templateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid regex — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback heuristic classification
|
||||||
|
if (/youtube\.com|youtu\.be/i.test(url)) setDetectedType('youtube');
|
||||||
|
else if (/twitter\.com|x\.com/i.test(url)) setDetectedType('tweet');
|
||||||
|
else if (/reddit\.com/i.test(url)) setDetectedType('reddit');
|
||||||
|
else if (/github\.com/i.test(url)) setDetectedType('github');
|
||||||
|
else if (/\.pdf(\?|$)/i.test(url)) setDetectedType('pdf');
|
||||||
|
else setDetectedType('article');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProcess(): Promise<void> {
|
||||||
|
if (!incomingUrl.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const submitResult = await submitIntake(
|
||||||
|
incomingUrl.trim(),
|
||||||
|
activeWorkspaceId ?? undefined,
|
||||||
|
selectedTemplate ?? undefined,
|
||||||
|
);
|
||||||
|
setResult(submitResult);
|
||||||
|
|
||||||
|
waitForJob(submitResult.jobId, (job) => {
|
||||||
|
if (job.status === 'complete') {
|
||||||
|
router.push(`/note/${job.noteId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Intake submission failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = getContentTypeBadge(detectedType);
|
||||||
|
const activeWorkspaceName =
|
||||||
|
workspaces.find((w: MobileWorkspace) => w.id === activeWorkspaceId)?.name ?? 'Default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.heading}>Process URL</Text>
|
||||||
|
<Text style={styles.hint}>Intake pipeline will extract, classify, and create a note from this URL.</Text>
|
||||||
|
|
||||||
|
{/* URL display */}
|
||||||
|
<View style={styles.urlCard}>
|
||||||
|
<Text style={styles.urlLabel}>URL</Text>
|
||||||
|
<Text style={styles.urlText} numberOfLines={3}>{incomingUrl || 'No URL provided'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content type badge */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Detected type</Text>
|
||||||
|
<View style={[styles.badge, { backgroundColor: badge.color + '22', borderColor: badge.color }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: badge.color }]}>{badge.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Matched rule */}
|
||||||
|
{matchedRule && (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Matched rule</Text>
|
||||||
|
<Text style={styles.value}>{matchedRule.name}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workspace */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Workspace</Text>
|
||||||
|
<Text style={styles.value}>{activeWorkspaceName}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Template override */}
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Template</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chipRow}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Auto-select template"
|
||||||
|
onPress={() => setSelectedTemplate(null)}
|
||||||
|
style={[styles.chip, selectedTemplate === null ? styles.chipActive : null]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, selectedTemplate === null ? styles.chipTextActive : null]}>
|
||||||
|
Auto
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{templates.slice(0, 8).map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t.id}
|
||||||
|
accessibilityLabel={`Select template ${t.name}`}
|
||||||
|
onPress={() => setSelectedTemplate(t.slug)}
|
||||||
|
style={[styles.chip, selectedTemplate === t.slug ? styles.chipActive : null]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, selectedTemplate === t.slug ? styles.chipTextActive : null]}>
|
||||||
|
{t.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
{/* Result: processing */}
|
||||||
|
{result && (
|
||||||
|
<View style={styles.resultCard}>
|
||||||
|
<ActivityIndicator color={colors.accentPrimary} size="small" />
|
||||||
|
<Text style={styles.resultText}>
|
||||||
|
Job queued — processing as {result.contentType}. You will be redirected when complete.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.resultMeta}>Job ID: {result.jobId}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Process button */}
|
||||||
|
{!result && (
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Process URL with intake pipeline"
|
||||||
|
onPress={() => void handleProcess()}
|
||||||
|
disabled={busy || !incomingUrl.trim()}
|
||||||
|
style={[styles.button, (busy || !incomingUrl.trim()) ? styles.buttonDisabled : null]}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={colors.textPrimary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Process</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
gap: 16,
|
||||||
|
backgroundColor: colors.bgCanvas,
|
||||||
|
minHeight: '100%',
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
urlCard: {
|
||||||
|
backgroundColor: colors.surfaceCard,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderDefault,
|
||||||
|
padding: 14,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
urlLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
urlText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.accentPrimary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
chipRow: {
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderDefault,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: colors.surfaceCard,
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: colors.accentPrimary,
|
||||||
|
borderColor: colors.accentPrimary,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
chipTextActive: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
backgroundColor: colors.surfaceCard,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accentPrimary,
|
||||||
|
padding: 14,
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
resultText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
resultMeta: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.accentPrimary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
||||||
import { usePromptStore, type PromptState } from '../../store/prompt-store';
|
import { usePromptStore, type PromptState } from '../../store/prompt-store';
|
||||||
import { getReadingTime, suggestTags } from '../../api/note-prompts';
|
import { getReadingTime, suggestTags } from '../../api/note-prompts';
|
||||||
@ -109,6 +109,39 @@ export default function NoteDetailScreen() {
|
|||||||
<Text style={styles.body}>Last updated: {formattedUpdatedAt}</Text>
|
<Text style={styles.body}>Last updated: {formattedUpdatedAt}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Share */}
|
||||||
|
{selectedNote && !isLoading && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Share</Text>
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Share note as text"
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
onPress={() => {
|
||||||
|
void Share.share({
|
||||||
|
message: `${selectedNote.title}\n\n${selectedNote.body}`,
|
||||||
|
title: selectedNote.title,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryButtonText}>Share as text</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Share deep link to note"
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
onPress={() => {
|
||||||
|
void Share.share({
|
||||||
|
message: `notelett://note/${noteId}`,
|
||||||
|
title: `NoteLett: ${selectedNote.title}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryButtonText}>Share link</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Smart Actions */}
|
{/* Smart Actions */}
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
64
mobile/src/store/intake-store.ts
Normal file
64
mobile/src/store/intake-store.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { listIntakeJobs, getIntakeJob, type IntakeJob } from '../api/intake';
|
||||||
|
|
||||||
|
export type IntakeState = {
|
||||||
|
activeJobs: IntakeJob[];
|
||||||
|
completedJobIds: string[];
|
||||||
|
isPolling: boolean;
|
||||||
|
pollActiveJobs: () => Promise<void>;
|
||||||
|
waitForJob: (jobId: string, onComplete: (job: IntakeJob) => void) => void;
|
||||||
|
clearCompleted: (jobId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_STATUSES = 'queued,extracting,processing';
|
||||||
|
|
||||||
|
export const useIntakeStore = create<IntakeState>((set, get) => ({
|
||||||
|
activeJobs: [],
|
||||||
|
completedJobIds: [],
|
||||||
|
isPolling: false,
|
||||||
|
|
||||||
|
async pollActiveJobs() {
|
||||||
|
if (get().isPolling) return;
|
||||||
|
set({ isPolling: true });
|
||||||
|
try {
|
||||||
|
const jobs = await listIntakeJobs({ status: POLL_STATUSES, limit: 50 });
|
||||||
|
set({ activeJobs: jobs, isPolling: false });
|
||||||
|
} catch {
|
||||||
|
set({ isPolling: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
waitForJob(jobId: string, onComplete: (job: IntakeJob) => void) {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 60;
|
||||||
|
const intervalMs = 3000;
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await getIntakeJob(jobId);
|
||||||
|
if (job.status === 'complete' || job.status === 'failed') {
|
||||||
|
clearInterval(timer);
|
||||||
|
set((state) => ({
|
||||||
|
activeJobs: state.activeJobs.filter((j) => j.id !== jobId),
|
||||||
|
completedJobIds: [...state.completedJobIds, jobId],
|
||||||
|
}));
|
||||||
|
onComplete(job);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Retry on next tick
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCompleted(jobId: string) {
|
||||||
|
set((state) => ({
|
||||||
|
completedJobIds: state.completedJobIds.filter((id) => id !== jobId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
Reference in New Issue
Block a user