Task Sharing Integration Guide for Developers
MIN-219: Task Sharing Phase 1 - Household Members Only
This guide provides comprehensive documentation for integrating the Task Sharing feature into your application or workflow. It covers API endpoints, WebSocket subscriptions, notification patterns, and best practices.
Table of Contents
- Overview
- Authentication Setup
- API Integration
- WebSocket Integration
- Notification Handling
- Error Handling
- Testing Strategies
- Best Practices
Overview
The Task Sharing feature enables household members to collaborate on tasks through a secure, real-time sharing system. The implementation includes:
- REST API: CRUD operations for task shares
- WebSocket (STOMP): Real-time notifications
- Redis: Distributed state management
- JWT Auth: Secure access control
Key Components
┌─────────────────┐
│ Frontend │
│ (React/Vite) │
└────────┬────────┘
│
├─── HTTP Requests ────► /api/v2/unified-tasks/{taskId}/share
│
└─── WebSocket ────────► /topic/task-shares/{customerId}
/topic/task-updates/{taskId}
Authentication Setup
JWT Token Requirements
All API requests and WebSocket connections require a valid JWT token with these custom claims:
{
"sub": "session-id-123",
"https://mindyournow.com/email": "[email protected]",
"https://mindyournow.com/username": "John Doe",
"aud": "https://mindyournow.com/api/",
"exp": 1737321600
}
Obtaining Test Tokens
For development and testing:
# Using the test token endpoint
curl -X POST https://api.myn.test/api/v1/customers/retrieve-test-token \
-H "X-API-KEY: myn_test_e2e" \
-H "Content-Type: application/json"
Production Authentication Flow
// Example using frontend magic link flow
async function authenticate() {
const response = await fetch('https://api.myn.test/api/v1/customers/exchange-magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magicLinkToken: token })
});
const { jwt } = await response.json();
localStorage.setItem('auth_token', jwt);
return jwt;
}
API Integration
Base Configuration
const API_BASE = 'https://api.myn.test/api/v2/unified-tasks';
const headers = {
'Authorization': `Bearer ${getAuthToken()}`,
'Content-Type': 'application/json'
};
1. Share a Task
POST /api/v2/unified-tasks/{taskId}/share
async function shareTask(taskId, memberIds, shareType, message) {
const response = await fetch(`${API_BASE}/${taskId}/share`, {
method: 'POST',
headers,
body: JSON.stringify({
member_ids: memberIds, // Array of UUIDs
share_type: shareType, // 'VIEW', 'EDIT', or 'DELEGATE'
message: message // Optional
})
});
if (!response.ok) {
throw new Error(`Share failed: ${response.statusText}`);
}
return await response.json();
}
// Usage
const result = await shareTask(
'task-uuid-123',
['550e8400-e29b-41d4-a716-446655440001'],
'EDIT',
'Please help with this task'
);
console.log('Share created:', result.assignment_id);
2. Get Pending Shares
GET /api/v2/unified-tasks/shared-with-me
async function getSharedTasks() {
const response = await fetch(`${API_BASE}/shared-with-me`, {
method: 'GET',
headers
});
const data = await response.json();
return {
pending: data.pending_shares, // Awaiting response
accepted: data.accepted_shares, // Active shares
total: data.total_count
};
}
// Usage
const shares = await getSharedTasks();
console.log(`You have ${shares.pending.length} pending invitations`);
3. Respond to Share
POST /api/v2/unified-tasks/{taskId}/share/respond
async function respondToShare(taskId, accept, note) {
const response = await fetch(`${API_BASE}/${taskId}/share/respond`, {
method: 'POST',
headers,
body: JSON.stringify({
response: accept ? 'ACCEPT' : 'DECLINE',
note: note // Optional
})
});
if (accept && response.ok) {
return await response.json(); // Returns updated TaskShareResponse
}
return null; // Decline returns 204 No Content
}
// Usage
await respondToShare('task-uuid-456', true, 'Happy to help!');
4. Revoke Share
DELETE /api/v2/unified-tasks/{taskId}/share/{memberId}
async function revokeShare(taskId, memberId) {
const response = await fetch(`${API_BASE}/${taskId}/share/${memberId}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
throw new Error(`Revoke failed: ${response.statusText}`);
}
// 204 No Content on success
return true;
}
// Usage
await revokeShare('task-uuid-123', '550e8400-e29b-41d4-a716-446655440001');
5. Get Task Shares (Owner Only)
GET /api/v2/unified-tasks/{taskId}/shares
async function getTaskShares(taskId) {
const response = await fetch(`${API_BASE}/${taskId}/shares`, {
method: 'GET',
headers
});
return await response.json(); // Array of TaskShareResponse
}
// Usage
const shares = await getTaskShares('task-uuid-123');
console.log(`Task shared with ${shares.length} members`);
WebSocket Integration
STOMP Client Setup
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
function setupWebSocket(authToken, customerId) {
const client = new Client({
webSocketFactory: () => new SockJS('https://api.myn.test/ws'),
connectHeaders: {
Authorization: `Bearer ${authToken}`
},
debug: (str) => console.log('[STOMP]', str),
onConnect: () => {
console.log('WebSocket connected');
// Subscribe to task share notifications
client.subscribe(`/topic/task-shares/${customerId}`, (message) => {
handleShareNotification(JSON.parse(message.body));
});
// Subscribe to task updates (optional)
client.subscribe(`/topic/task-updates/*`, (message) => {
handleTaskUpdate(JSON.parse(message.body));
});
},
onStompError: (frame) => {
console.error('STOMP error:', frame);
}
});
client.activate();
return client;
}
Subscription Topics
1. Task Share Notifications
Topic: /topic/task-shares/{customerId}
Receives notifications when:
- Someone shares a task with you
- Someone responds to your share (accept/decline)
- A share is revoked
function handleShareNotification(notification) {
const { type, taskId, assignment } = notification;
switch (type) {
case 'SHARE_CREATED':
showNotification(`New task shared: ${assignment.task_title}`);
refreshPendingShares();
break;
case 'SHARE_ACCEPTED':
showNotification(`${assignment.shared_with_name} accepted your share`);
break;
case 'SHARE_DECLINED':
showNotification(`${assignment.shared_with_name} declined your share`);
break;
case 'SHARE_REVOKED':
showNotification('A task share was revoked');
removeFromSharedTasks(taskId);
break;
}
}
2. Task Updates
Topic: /topic/task-updates/{taskId}
Receives real-time updates when shared tasks are modified:
function handleTaskUpdate(update) {
const { taskId, field, newValue, updatedBy } = update;
if (field === 'title') {
updateTaskTitle(taskId, newValue);
} else if (field === 'status') {
updateTaskStatus(taskId, newValue);
}
showNotification(`${updatedBy} updated the task`);
}
Reconnection Strategy
function setupReconnection(client) {
client.reconnectDelay = 5000; // 5 seconds
client.heartbeatIncoming = 10000; // 10 seconds
client.heartbeatOutgoing = 10000;
client.onWebSocketClose = () => {
console.warn('WebSocket closed, will attempt reconnect');
};
}
Notification Handling
Push Notification Integration
The backend sends push notifications via Firebase Cloud Messaging (FCM) when:
- A task is shared with you (when offline)
- Someone accepts/declines your share
- A shared task is updated
// Frontend service worker registration
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(registration => {
messaging.useServiceWorker(registration);
messaging.onMessage((payload) => {
const { title, body, data } = payload.notification;
if (data.type === 'TASK_SHARE') {
showInAppNotification(title, body, {
taskId: data.taskId,
assignmentId: data.assignmentId
});
}
});
});
}
Notification Badge Updates
async function updateNotificationBadge() {
const shares = await getSharedTasks();
const pendingCount = shares.pending.length;
// Update UI badge
document.querySelector('.notification-badge').textContent = pendingCount;
document.querySelector('.notification-badge').style.display =
pendingCount > 0 ? 'block' : 'none';
}
// Call on page load and after WebSocket notifications
updateNotificationBadge();
Error Handling
API Error Responses
All endpoints return standard HTTP status codes with JSON error bodies:
async function handleApiError(response) {
if (!response.ok) {
const error = await response.json();
switch (response.status) {
case 400:
// Bad request - validation error
throw new Error(error.message || 'Invalid request');
case 401:
// Unauthorized - token expired or invalid
redirectToLogin();
break;
case 403:
// Forbidden - not authorized for this action
showError('You do not have permission to perform this action');
break;
case 404:
// Not found - task or member doesn't exist
showError('Task or member not found');
break;
case 500:
// Server error
showError('Server error. Please try again later.');
break;
}
}
}
Specific Error Scenarios
1. Sharing Non-Sharable Tasks
try {
await shareTask('chore-uuid-123', memberIds, 'EDIT');
} catch (error) {
if (error.message.includes('Chores cannot be shared')) {
showWarning('Chores are household-specific and cannot be shared');
} else if (error.message.includes('Habits cannot be shared')) {
showWarning('Habits are personal and cannot be shared');
}
}
2. Household Boundary Violations
try {
await shareTask(taskId, ['member-from-different-household'], 'EDIT');
} catch (error) {
if (error.message.includes('household')) {
showError('You can only share tasks with members from your households');
}
}
3. WebSocket Errors
client.onStompError = (frame) => {
console.error('STOMP error:', frame);
if (frame.headers['message'].includes('Authentication')) {
// Token expired, refresh and reconnect
refreshAuthToken().then(newToken => {
client.deactivate();
client.connectHeaders.Authorization = `Bearer ${newToken}`;
client.activate();
});
}
};
Testing Strategies
Unit Testing API Calls
import { describe, it, expect, vi } from 'vitest';
describe('Task Sharing API', () => {
it('should share task with household member', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
assignment_id: 123,
task_id: 'task-uuid-123',
status: 'PENDING'
})
});
const result = await shareTask('task-uuid-123', [memberUuid], 'EDIT');
expect(result.assignment_id).toBe(123);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/share'),
expect.objectContaining({ method: 'POST' })
);
});
it('should handle share rejection', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ message: 'Chores cannot be shared' })
});
await expect(shareTask('chore-123', [memberUuid], 'EDIT'))
.rejects.toThrow('Chores cannot be shared');
});
});
Integration Testing with Playwright
import { test, expect } from '@playwright/test';
test('should share task and receive notification', async ({ page, context }) => {
// Login as user 1
await page.goto('https://myn.test/magic-login?directtoken=' + token1);
await page.waitForURL(/.*\/(home|settings)/);
// Share a task
await page.click('[data-testid="task-123"]');
await page.click('[data-testid="share-button"]');
await page.selectOption('[data-testid="member-select"]', memberUuid);
await page.selectOption('[data-testid="share-type"]', 'EDIT');
await page.click('[data-testid="send-share"]');
// Verify success message
await expect(page.locator('.toast-success')).toContainText('Task shared');
// Open second tab as user 2
const page2 = await context.newPage();
await page2.goto('https://myn.test/magic-login?directtoken=' + token2);
// Verify notification appears
await expect(page2.locator('.notification-badge')).toContainText('1');
await page2.click('[data-testid="notifications"]');
await expect(page2.locator('.pending-share')).toContainText('shared with you');
});
WebSocket Testing
import { vi } from 'vitest';
describe('WebSocket Notifications', () => {
let client;
beforeEach(() => {
client = setupWebSocket(mockToken, mockCustomerId);
});
afterEach(() => {
client.deactivate();
});
it('should receive share notification', async () => {
const handleNotification = vi.fn();
client.subscribe(`/topic/task-shares/${mockCustomerId}`, (message) => {
handleNotification(JSON.parse(message.body));
});
// Simulate backend sending notification
const notification = {
type: 'SHARE_CREATED',
taskId: 'task-uuid-123',
assignment: { task_title: 'Test Task' }
};
// Wait for notification
await new Promise(resolve => setTimeout(resolve, 100));
expect(handleNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: 'SHARE_CREATED' })
);
});
});
Best Practices
1. Optimistic UI Updates
async function acceptShare(taskId) {
// Immediately update UI (optimistic)
updateUIShareStatus(taskId, 'ACCEPTED');
try {
await respondToShare(taskId, true);
} catch (error) {
// Revert on error
updateUIShareStatus(taskId, 'PENDING');
showError('Failed to accept share');
}
}
2. Caching Strategy
class ShareCache {
constructor() {
this.cache = new Map();
this.ttl = 5 * 60 * 1000; // 5 minutes
}
async getSharedTasks(customerId, forceRefresh = false) {
const cached = this.cache.get(customerId);
if (!forceRefresh && cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
const data = await getSharedTasks();
this.cache.set(customerId, { data, timestamp: Date.now() });
return data;
}
invalidate(customerId) {
this.cache.delete(customerId);
}
}
3. Batch Operations
async function shareWithMultipleMembers(taskId, memberIds, shareType) {
// Single API call handles multiple members
return await shareTask(taskId, memberIds, shareType);
}
// Instead of:
// for (const memberId of memberIds) {
// await shareTask(taskId, [memberId], shareType); // N requests
// }
4. Security Considerations
// Always validate member IDs on frontend
function validateMemberIds(memberIds, householdMembers) {
return memberIds.filter(id =>
householdMembers.some(member => member.id === id)
);
}
// Sanitize user input for messages
function sanitizeMessage(message) {
return message
.replace(/</g, '<')
.replace(/>/g, '>')
.substring(0, 500); // Limit length
}
// Never expose sensitive data in logs
console.log('Sharing task:', { taskId, memberCount: memberIds.length });
// DON'T: console.log('Member IDs:', memberIds);
5. Error Recovery
class ResilientShareClient {
constructor() {
this.retryAttempts = 3;
this.retryDelay = 1000;
}
async shareWithRetry(taskId, memberIds, shareType) {
for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
try {
return await shareTask(taskId, memberIds, shareType);
} catch (error) {
if (attempt === this.retryAttempts) throw error;
console.warn(`Share attempt ${attempt} failed, retrying...`);
await new Promise(resolve =>
setTimeout(resolve, this.retryDelay * attempt)
);
}
}
}
}
Complete Integration Example
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
class TaskSharingIntegration {
constructor(apiBase, authToken, customerId) {
this.apiBase = apiBase;
this.authToken = authToken;
this.customerId = customerId;
this.wsClient = null;
this.cache = new ShareCache();
}
// Initialize WebSocket connection
async connect() {
this.wsClient = new Client({
webSocketFactory: () => new SockJS(`${this.apiBase.replace('/api', '')}/ws`),
connectHeaders: { Authorization: `Bearer ${this.authToken}` },
onConnect: () => this.onConnected(),
onStompError: (frame) => this.onError(frame)
});
this.wsClient.activate();
}
onConnected() {
console.log('WebSocket connected');
this.wsClient.subscribe(`/topic/task-shares/${this.customerId}`, (message) => {
this.handleShareNotification(JSON.parse(message.body));
});
}
handleShareNotification(notification) {
this.cache.invalidate(this.customerId);
this.emit('shareNotification', notification);
}
// Share a task
async shareTask(taskId, memberIds, shareType, message) {
const response = await fetch(`${this.apiBase}/unified-tasks/${taskId}/share`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
member_ids: memberIds,
share_type: shareType,
message: message
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Share failed');
}
return await response.json();
}
// Get shared tasks with caching
async getSharedTasks(forceRefresh = false) {
return await this.cache.getSharedTasks(this.customerId, forceRefresh);
}
// Respond to share
async respondToShare(taskId, accept, note) {
const response = await fetch(`${this.apiBase}/unified-tasks/${taskId}/share/respond`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
response: accept ? 'ACCEPT' : 'DECLINE',
note: note
})
});
if (!response.ok) throw new Error('Response failed');
this.cache.invalidate(this.customerId);
return accept ? await response.json() : null;
}
// Disconnect
disconnect() {
if (this.wsClient) {
this.wsClient.deactivate();
}
}
// Event emitter pattern
emit(event, data) {
const customEvent = new CustomEvent(event, { detail: data });
window.dispatchEvent(customEvent);
}
}
// Usage
const integration = new TaskSharingIntegration(
'https://api.myn.test/api/v2',
authToken,
customerId
);
await integration.connect();
// Listen for notifications
window.addEventListener('shareNotification', (event) => {
console.log('Share notification:', event.detail);
});
// Share a task
await integration.shareTask('task-123', [memberUuid], 'EDIT', 'Help needed');
// Get shared tasks
const shares = await integration.getSharedTasks();
console.log('Pending shares:', shares.pending);
Additional Resources
- OpenAPI Documentation: https://api.myn.test/swagger-ui/index.html
- API Reference: See
docs/task-sharing-api-documentation.md - Backend Source:
mind-your-now-api/src/main/java/com/myn/controllers/TaskSharingController.java - Frontend Implementation:
mind-your-now/src/services/taskSharingService.js
Last Updated: 2025-01-19 MIN Ticket: MIN-219 API Version: v2