Monorepo for Aesthetic.Computer aesthetic.computer
core at MOODS-SIMPLE-PLAN.md
18 kB

🦋 Moods ATProto Integration Plan (Simplified) #

Date: October 14, 2025
Status: Planning


📊 Current MongoDB Schema #

// Collection: moods
{
  _id: ObjectId("..."),
  user: "auth0|123...",     // User sub
  mood: "feeling good",     // Text (can be multiline)
  when: ISODate("..."),     // Timestamp
  deleted: false            // Soft delete flag (optional)
}

// Indexes:
// - user: 1
// - when: 1

Source: /system/netlify/functions/mood.mjs lines 106-109


🦋 ATProto Lexicon (Matching MongoDB) #

{
  "lexicon": 1,
  "id": "computer.aesthetic.mood",
  "defs": {
    "main": {
      "type": "record",
      "key": "tid",
      "record": {
        "required": ["mood", "when"],
        "properties": {
          "mood": {
            "type": "string",
            "maxLength": 5000
          },
          "when": {
            "type": "string",
            "format": "datetime"
          },
          "mongoId": {
            "type": "string"
          }
        }
      }
    }
  }
}

File: /at/lexicons/computer/aesthetic/mood.json ✅ Created


🔄 MongoDB Schema Update #

Add optional atproto field to moods:

{
  _id: ObjectId("..."),
  user: "auth0|123...",
  mood: "feeling good",
  when: ISODate("..."),
  deleted: false,
  atproto: {                              // NEW (optional)
    uri: "at://did:plc:abc.../computer.aesthetic.mood/3k...",
    cid: "bafyrei...",
    created: ISODate("..."),
    synced: true
  }
}

No migration needed - field is optional and added on new moods only.


🛠️ Implementation Steps #

Phase 1: Create Helper Functions #

File: /system/backend/mood-atproto.mjs (new)

import { AtpAgent } from '@atproto/api';

const PDS_URL = process.env.PDS_URL || 'https://at.aesthetic.computer';

/**
 * Create ATProto mood record
 * @param {Object} database - MongoDB connection
 * @param {string} userSub - auth0|... user ID
 * @param {string} moodText - The mood text
 * @param {Date} timestamp - When the mood was created
 * @param {string} mongoId - MongoDB _id as string
 */
export async function createAtprotoMood(database, userSub, moodText, timestamp, mongoId) {
  try {
    const users = database.db.collection('users');
    const user = await users.findOne({ _id: userSub });
    
    if (!user?.atproto?.did || !user?.atproto?.password) {
      console.log(`ℹ️  User ${userSub} has no ATProto account, skipping mood sync`);
      return { created: false, reason: 'no_atproto_account' };
    }

    const agent = new AtpAgent({ service: PDS_URL });
    await agent.login({
      identifier: user.atproto.did,
      password: user.atproto.password,
    });

    const record = {
      $type: 'computer.aesthetic.mood',
      mood: moodText,
      when: timestamp.toISOString(),
      mongoId: mongoId,
      createdAt: new Date().toISOString(),
    };

    const result = await agent.com.atproto.repo.createRecord({
      repo: user.atproto.did,
      collection: 'computer.aesthetic.mood',
      record,
    });

    console.log(`✅ ATProto mood created: ${result.uri}`);
    
    return {
      created: true,
      uri: result.uri,
      cid: result.cid,
    };
  } catch (error) {
    console.error(`❌ Failed to create ATProto mood: ${error.message}`);
    return { created: false, error: error.message };
  }
}

/**
 * Delete ATProto mood record
 * @param {Object} database - MongoDB connection
 * @param {string} userSub - auth0|... user ID  
 * @param {string} atprotoUri - AT-URI of the mood record
 */
export async function deleteAtprotoMood(database, userSub, atprotoUri) {
  try {
    const users = database.db.collection('users');
    const user = await users.findOne({ _id: userSub });
    
    if (!user?.atproto?.did || !user?.atproto?.password) {
      return { deleted: false, reason: 'no_atproto_account' };
    }

    const agent = new AtpAgent({ service: PDS_URL });
    await agent.login({
      identifier: user.atproto.did,
      password: user.atproto.password,
    });

    // Parse AT-URI: at://did:plc:abc.../computer.aesthetic.mood/3k...
    const parts = atprotoUri.replace('at://', '').split('/');
    const rkey = parts[parts.length - 1];

    await agent.com.atproto.repo.deleteRecord({
      repo: user.atproto.did,
      collection: 'computer.aesthetic.mood',
      rkey,
    });

    console.log(`✅ ATProto mood deleted: ${atprotoUri}`);
    
    return { deleted: true };
  } catch (error) {
    console.error(`❌ Failed to delete ATProto mood: ${error.message}`);
    return { deleted: false, error: error.message };
  }
}

/**
 * Delete all ATProto moods for a user (for delete-erase-and-forget-me)
 * @param {Object} database - MongoDB connection
 * @param {string} userSub - auth0|... user ID
 */
export async function deleteAllAtprotoMoods(database, userSub) {
  try {
    const users = database.db.collection('users');
    const moods = database.db.collection('moods');
    
    const user = await users.findOne({ _id: userSub });
    
    if (!user?.atproto?.did || !user?.atproto?.password) {
      return { deleted: 0, reason: 'no_atproto_account' };
    }

    const agent = new AtpAgent({ service: PDS_URL });
    await agent.login({
      identifier: user.atproto.did,
      password: user.atproto.password,
    });

    // Get all moods with ATProto URIs
    const userMoods = await moods.find({
      user: userSub,
      'atproto.uri': { $exists: true },
    }).toArray();

    let deleted = 0;
    for (const mood of userMoods) {
      const result = await deleteAtprotoMood(database, userSub, mood.atproto.uri);
      if (result.deleted) deleted++;
    }

    console.log(`✅ Deleted ${deleted}/${userMoods.length} ATProto moods for ${userSub}`);
    
    return { deleted, total: userMoods.length };
  } catch (error) {
    console.error(`❌ Failed to delete ATProto moods: ${error.message}`);
    return { deleted: 0, error: error.message };
  }
}

Phase 2: Update mood.mjs Function #

File: /system/netlify/functions/mood.mjs

Current code (lines 106-109):

await collection.insertOne({
  user: user.sub,
  mood,
  when: new Date(),
});

Updated code:

import { createAtprotoMood } from '../../backend/mood-atproto.mjs';

// ... in the handler function after line 109:

const timestamp = new Date();
const result = await collection.insertOne({
  user: user.sub,
  mood,
  when: timestamp,
});

// Sync to ATProto (non-blocking)
try {
  const atprotoResult = await createAtprotoMood(
    database,
    user.sub,
    mood,
    timestamp,
    result.insertedId.toString()
  );
  
  if (atprotoResult.created) {
    // Update MongoDB with ATProto URI
    await collection.updateOne(
      { _id: result.insertedId },
      {
        $set: {
          atproto: {
            uri: atprotoResult.uri,
            cid: atprotoResult.cid,
            created: new Date(),
            synced: true,
          }
        }
      }
    );
    console.log(`✅ Mood synced to ATProto: ${atprotoResult.uri}`);
  }
} catch (error) {
  // Don't fail the request if ATProto sync fails
  console.error(`⚠️  ATProto sync failed (non-fatal): ${error.message}`);
}

Phase 3: Update delete-erase-and-forget-me #

File: /system/netlify/functions/delete-erase-and-forget-me.mjs

Add import:

import { deleteAllAtprotoMoods } from '../../backend/mood-atproto.mjs';

Find the moods deletion section and add:

// After deleting from MongoDB
const atprotoResult = await deleteAllAtprotoMoods(database, user.sub);
console.log(`🦋 ATProto moods deleted: ${atprotoResult.deleted}`);

Phase 4: Backfill Historical Moods #

File: /at/scripts/migrate-moods-to-atproto.mjs (new)

#!/usr/bin/env node
// migrate-moods-to-atproto.mjs
// Backfill existing moods from MongoDB to ATProto

import { connect } from '../../system/backend/database.mjs';
import { createAtprotoMood } from '../../system/backend/mood-atproto.mjs';
import { config } from 'dotenv';

config();

async function migrateUserMoods(database, userSub, limit = null, dryRun = true) {
  console.log(`\n📋 Migrating moods for: ${userSub}`);
  console.log(`   Dry run: ${dryRun ? 'YES' : 'NO'}`);
  if (limit) console.log(`   Limit: ${limit} moods`);
  
  const moods = database.db.collection('moods');
  
  // Find moods without ATProto URI
  const query = {
    user: userSub,
    deleted: { $ne: true },
    'atproto.uri': { $exists: false },
  };
  
  const moodsToMigrate = await moods.find(query)
    .sort({ when: 1 })  // Oldest first
    .limit(limit || 0)
    .toArray();
  
  console.log(`   Found ${moodsToMigrate.length} moods to migrate\n`);
  
  if (dryRun) {
    console.log('   ⚠️  Dry run - no changes made\n');
    return { migrated: 0, total: moodsToMigrate.length };
  }
  
  let migrated = 0;
  for (const mood of moodsToMigrate) {
    console.log(`   Processing: ${mood._id} - "${mood.mood.substring(0, 50)}..."`);
    
    const result = await createAtprotoMood(
      database,
      mood.user,
      mood.mood,
      mood.when,
      mood._id.toString()
    );
    
    if (result.created) {
      await moods.updateOne(
        { _id: mood._id },
        {
          $set: {
            atproto: {
              uri: result.uri,
              cid: result.cid,
              created: new Date(),
              synced: true,
            }
          }
        }
      );
      migrated++;
      console.log(`      ✅ Migrated: ${result.uri}`);
    } else {
      console.log(`      ⚠️  Skipped: ${result.reason || result.error}`);
    }
    
    // Rate limiting (1 per second)
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
  
  console.log(`\n✅ Migration complete: ${migrated}/${moodsToMigrate.length} moods\n`);
  return { migrated, total: moodsToMigrate.length };
}

async function migrateAllUsers(limit = null, dryRun = true) {
  const database = await connect();
  
  try {
    const users = database.db.collection('users');
    const moods = database.db.collection('moods');
    
    // Find users with ATProto accounts who have moods
    const usersWithAtproto = await users.find({
      'atproto.did': { $exists: true },
    }).toArray();
    
    console.log(`\n📊 Found ${usersWithAtproto.length} users with ATProto accounts\n`);
    
    for (const user of usersWithAtproto) {
      const moodCount = await moods.countDocuments({
        user: user._id,
        deleted: { $ne: true },
      });
      
      if (moodCount === 0) continue;
      
      console.log(`\n${'='.repeat(80)}`);
      await migrateUserMoods(database, user._id, limit, dryRun);
    }
    
  } finally {
    await database.disconnect();
  }
}

// Parse command line args
const args = process.argv.slice(2);
const dryRun = !args.includes('--execute');
const limitArg = args.find(arg => arg.startsWith('--limit='));
const limit = limitArg ? parseInt(limitArg.split('=')[1]) : null;
const userArg = args.find(arg => arg.startsWith('auth0|') || arg.startsWith('@'));

if (dryRun) {
  console.log('\n⚠️  DRY RUN MODE - No changes will be made');
  console.log('   Add --execute flag to actually migrate\n');
}

if (userArg) {
  // Migrate single user
  const database = await connect();
  try {
    const userSub = userArg.startsWith('@') 
      ? await userIDFromHandleOrEmail(userArg, database)
      : userArg;
    await migrateUserMoods(database, userSub, limit, dryRun);
  } finally {
    await database.disconnect();
  }
} else {
  // Migrate all users
  await migrateAllUsers(limit, dryRun);
}

Usage:

# Dry run for single user
node at/scripts/migrate-moods-to-atproto.mjs auth0|123...

# Dry run for all users
node at/scripts/migrate-moods-to-atproto.mjs

# Actually migrate (single user, limit 10)
node at/scripts/migrate-moods-to-atproto.mjs auth0|123... --limit=10 --execute

# Migrate all users
node at/scripts/migrate-moods-to-atproto.mjs --execute

Phase 5: Audit & Verification #

File: /at/scripts/audit-mood-atproto-sync.mjs (new)

#!/usr/bin/env node
// audit-mood-atproto-sync.mjs
// Verify moods sync between MongoDB and ATProto

import { connect } from '../../system/backend/database.mjs';
import { AtpAgent } from '@atproto/api';
import { config } from 'dotenv';

config();

const PDS_URL = process.env.PDS_URL || 'https://at.aesthetic.computer';

async function auditUser(database, userSub) {
  console.log(`\n${'='.repeat(80)}`);
  console.log(`📊 Auditing moods for: ${userSub}\n`);
  
  const users = database.db.collection('users');
  const moods = database.db.collection('moods');
  
  const user = await users.findOne({ _id: userSub });
  if (!user) {
    console.log('❌ User not found\n');
    return;
  }
  
  console.log(`   User: ${user._id}`);
  console.log(`   Handle: ${user.atproto?.handle || 'N/A'}`);
  console.log(`   DID: ${user.atproto?.did || 'N/A'}`);
  
  // MongoDB stats
  const totalMoods = await moods.countDocuments({
    user: userSub,
    deleted: { $ne: true },
  });
  const moodsWithAtproto = await moods.countDocuments({
    user: userSub,
    deleted: { $ne: true },
    'atproto.uri': { $exists: true },
  });
  
  console.log(`\n   MongoDB Moods:`);
  console.log(`      Total: ${totalMoods}`);
  console.log(`      With ATProto: ${moodsWithAtproto}`);
  console.log(`      Without ATProto: ${totalMoods - moodsWithAtproto}`);
  
  // ATProto stats (if user has account)
  if (user.atproto?.did && user.atproto?.password) {
    try {
      const agent = new AtpAgent({ service: PDS_URL });
      await agent.login({
        identifier: user.atproto.did,
        password: user.atproto.password,
      });
      
      const result = await agent.com.atproto.repo.listRecords({
        repo: user.atproto.did,
        collection: 'computer.aesthetic.mood',
      });
      
      console.log(`\n   ATProto PDS:`);
      console.log(`      Total records: ${result.data.records.length}`);
      
      // Sample recent moods
      console.log(`\n   Recent moods (last 5):\n`);
      const recentMoods = await moods.find({
        user: userSub,
        deleted: { $ne: true },
      })
      .sort({ when: -1 })
      .limit(5)
      .toArray();
      
      recentMoods.forEach(mood => {
        console.log(`      ${mood.when.toISOString()}: "${mood.mood.substring(0, 50)}..."`);
        console.log(`         ATProto: ${mood.atproto?.uri ? '✅' : '❌'}`);
      });
      
    } catch (error) {
      console.log(`\n   ❌ Failed to query ATProto: ${error.message}`);
    }
  } else {
    console.log(`\n   ℹ️  User has no ATProto account`);
  }
  
  console.log(`\n${'='.repeat(80)}\n`);
}

// Parse args
const args = process.argv.slice(2);
const userArg = args[0];

if (!userArg) {
  console.log('Usage: node audit-mood-atproto-sync.mjs <auth0|...>');
  process.exit(1);
}

const database = await connect();
try {
  await auditUser(database, userArg);
} finally {
  await database.disconnect();
}

📋 Testing Checklist #

Unit Tests #

Integration Tests #

Migration Tests #


🚨 Important Notes #

What This Does #

✅ Adds ATProto support to existing moods
✅ Maintains MongoDB as primary (no breaking changes)
✅ Gracefully handles missing ATProto accounts
✅ Enables federated mood discovery

What This Doesn't Do #

❌ Change existing mood schema (just adds optional field)
❌ Require ATProto for moods to work
❌ Break existing integrations
❌ Expose private data

Edge Cases #

  • No ATProto account: Mood saved to MongoDB only
  • ATProto PDS down: Mood saved to MongoDB, sync logged as failed
  • Duplicate moods: De-duped by checking last mood text
  • Soft delete: MongoDB marked deleted, ATProto keeps (immutable)
  • Hard delete: Both systems cleared

📞 Quick Commands #

# Test single mood creation
curl -X POST https://aesthetic.computer/api/mood \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"mood": "testing atproto!"}'

# Audit user sync
node at/scripts/audit-mood-atproto-sync.mjs auth0|123...

# Migrate user (dry run)
node at/scripts/migrate-moods-to-atproto.mjs auth0|123...

# Migrate user (execute)
node at/scripts/migrate-moods-to-atproto.mjs auth0|123... --execute

# Migrate all users with limit
node at/scripts/migrate-moods-to-atproto.mjs --limit=100 --execute

🎉 Success Criteria #


Status: ✅ Plan complete - Ready for implementation
Next Step: Create /system/backend/mood-atproto.mjs helper functions