Skip to main content

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

  1. Overview
  2. Authentication Setup
  3. API Integration
  4. WebSocket Integration
  5. Notification Handling
  6. Error Handling
  7. Testing Strategies
  8. 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, '&lt;')
.replace(/>/g, '&gt;')
.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