Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/models/LASTOPENEDAT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# `lastOpenedAt` — operating notes

## What it is

A Date field on the `User` schema that tracks **real app-open events** — when an
authenticated user genuinely opens or re-opens the AXS Map app.

Distinct from existing fields:

| Field | When set | Semantics |
|---|---|---|
| `lastLogin` | Sign-in flows | Last successful password / social sign-in |
| `lastActivityTime` | ⚠️ **Every authenticated API call** (`helpers/index.js:isAuthenticated`) | Way too noisy — every background poll updates this. Source of the May 2026 user-status bug. |
| **`lastOpenedAt`** | **Sign-in flows + token refresh** | Real app-open signal. Used by future Salesforce User Status logic. |

## Where it IS set (only these places)

| File | When |
|---|---|
| `routes/auth/sign-in.js` | Successful email+password sign-in |
| `routes/auth/apple-sign-in.js` | Successful Apple sign-in (new or existing user) |
| `routes/auth/google-sign-in.js` | Successful Google sign-in (new or existing user) |
| `routes/auth/facebook-sign-in.js` | Successful Facebook sign-in (new or existing user) |
| `routes/auth/generate-token.js` | Successful refresh-token → new JWT (the AXS Map app was re-opened after the previous JWT expired) |

Every update logs to stdout:
```
[app-open] sign-in: userId=<id> lastOpenedAt=2026-05-23T18:00:00.000Z
[app-open] google-sign-in (existing): userId=<id> lastOpenedAt=...
[app-open] token-refresh: userId=<id> lastOpenedAt=...
```

This is the audit trail — easy to grep for in CloudWatch / log aggregators.

## Where it is NOT set (and must NEVER be set)

- ❌ `helpers/index.js:isAuthenticated` middleware — this was the trap with
`lastActivityTime`. Setting any "user activity" field on every API request
means even silent background polling counts as activity. **Do not extend this.**
- ❌ Any of the AWS Lambda sync functions (`axs-map-sync-users` etc.) — they
don't read or write this field. Confirmed by code grep.
- ❌ Admin bulk scripts in `src/scripts/db/*` — `import-users.js`,
`update-users-avatars.js`, `migrate-scores.js` — none touch this field.
- ❌ MongoDB Atlas Triggers — they fire change events to AWS EventBridge but
don't update the source doc.
- ❌ Salesforce → MongoDB direction — there is no sync in that direction today.

## How to verify before/after a deploy

```bash
cd "/Users/saffiullah/AXS Map API"

# Snapshot — read schema definition and population counts
node src/scripts/verify-last-opened-at.js

# Watch a specific user in real time (do this BEFORE signing in via the app):
node src/scripts/verify-last-opened-at.js --watch --user me@example.com

# Then sign in via the app or POST /auth/sign-in. You should see a single line:
# [<time>] lastOpenedAt changed: <old> → <new>
# If you see multiple updates from a single sign-in, or updates with no sign-in,
# something else is writing the field — find and remove the writer.
```

## Salesforce side

The matching SF field already exists:

| SF Object | Field | Type | Population (as of 2026-05-23) |
|---|---|---|---|
| `Contact` | `Last_App_Opened_At__c` | Date/Time | 0 Contacts populated |

It exists but is **not yet synced** — per this ticket's scope ("Do not change
Salesforce User Status logic in this ticket"), the Lambda is NOT mapping
`lastOpenedAt` → `Last_App_Opened_At__c` yet. Seeding now would let the User
Status flow start consuming it, which is the next ticket's work.

When the next ticket lands, it must:

1. **Add FLS to the integration's permission set.** The Lambda's run-as user
uses `AXS_Map_AWS_Lambda_Integration` perm set, which currently has **no**
FLS for `Last_App_Opened_At__c`. Without this, the sync would fail with
`INSUFFICIENT_ACCESS`. The deploy is one block in:
`force-app/main/default/permissionsets/AXS_Map_AWS_Lambda_Integration.permissionset-meta.xml`

```xml
<fieldPermissions>
<editable>true</editable>
<field>Contact.Last_App_Opened_At__c</field>
<readable>true</readable>
</fieldPermissions>
```

2. **Add the field to the Lambda payload** in
`/Users/saffiullah/AXS Map AWS/lambdas/sync-users/index.js:buildContact()`.
IMPORTANT: use the same omit-when-Mongo-absent pattern that was applied to
`lastActivityTime__c` in the recent fix — otherwise we'd recreate the May
2026 bulk-overwrite bug:

```js
// Only include if Mongo has a value — never fall back to Date.now() or
// any default; omit the field entirely if Mongo has nothing to say.
const opened = core.toDateTime(document.lastOpenedAt);
if (opened) {
contact.Last_App_Opened_At__c = opened;
core.logDebug('users: setting Last_App_Opened_At__c from Mongo', { mongoId, value: opened });
}
```

3. **Run the audit script** to confirm the perm set now grants the field:
`node "/Users/saffiullah/AXS Map AWS/scripts/audit-lambda-perms.js"`

4. **Update the User Status flow** to read `Last_App_Opened_At__c` instead of
`lastActivityTime__c`. The flow is at
`Setup → Flows → Welcome Email Trigger Flow on Contact` (or whichever
actually contains the active/inactive logic).
9 changes: 9 additions & 0 deletions src/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ const userSchema = new mongoose.Schema(
type: Date,
default: null,
},
// Tracks REAL app-open events only (sign-in, social sign-in, token refresh).
// Intentionally NOT touched by middleware/syncs/admin scripts — see ticket
// "Add Mongo lastOpenedAt Field and Prepare Salesforce User Status Migration".
// Salesforce User Status logic will eventually migrate from lastActivityTime
// to this field. Do NOT set this from background jobs.
lastOpenedAt: {
type: Date,
default: null,
},
lastLocation: {
type: {
lat: {
Expand Down
14 changes: 9 additions & 5 deletions src/routes/auth/apple-sign-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,32 @@ module.exports = async (req, res, next) => {
let user = await User.findOne({
email: appleResponse?.email,
});
const now = new Date();
if (!user) {
user = new User({
email: appleResponse?.email,
firstName:appleResponse?.fullName?.givenName ?? "",
lastName:appleResponse?.fullName?.familyName ?? "",
appleId: appleResponse?.sub,
lastLogin: new Date(),
lastLogin: now,
lastOpenedAt: now,
});

await user.save();
console.log(`[app-open] apple-sign-in (new): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
} else {
// Check if user is archived
if (user.isArchived) {
return res.status(403).json({
return res.status(403).json({
error: "Account archived",
isArchived: true,
userId: user._id.toString()
});
}

// Update lastLogin for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: new Date() });

// Real app-open: update lastLogin AND lastOpenedAt for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: now, lastOpenedAt: now });
console.log(`[app-open] apple-sign-in (existing): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
}

const userId = user._id;
Expand Down
14 changes: 9 additions & 5 deletions src/routes/auth/facebook-sign-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ module.exports = async (req, res, next) => {
const email = fbUser.email;

let user = await User.findOne({ email: fbUser.email });
const now = new Date();

if (!user) {
const [firstName, lastName] = fbUser.name.split(" ");
Expand All @@ -57,22 +58,25 @@ module.exports = async (req, res, next) => {
firstName: firstName || "",
lastName: lastName || "",
avatar: fbUser.picture.data.url,
lastLogin: new Date(),
lastLogin: now,
lastOpenedAt: now,
});

await user.save();
console.log(`[app-open] facebook-sign-in (new): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
} else {
// Check if user is archived
if (user.isArchived) {
return res.status(403).json({
return res.status(403).json({
error: "Account archived",
isArchived: true,
userId: user._id.toString()
});
}

// Update lastLogin for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: new Date() });

// Real app-open: update both lastLogin and lastOpenedAt for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: now, lastOpenedAt: now });
console.log(`[app-open] facebook-sign-in (existing): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
}

const userId = user._id;
Expand Down
14 changes: 14 additions & 0 deletions src/routes/auth/generate-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const jwt = require("jsonwebtoken");
const moment = require("moment");

const { RefreshToken } = require("../../models/refresh-token");
const { User } = require("../../models/user");

const { validateGenerateToken } = require("./validations");

Expand Down Expand Up @@ -51,5 +52,18 @@ module.exports = async (req, res, next) => {
expiresIn: '30d',
}
);

// Token refresh = the AXS Map app is being reopened (the user's previous
// JWT expired and the client is exchanging the long-lived refresh token).
// That's a real app-open event — set lastOpenedAt.
try {
const now = new Date();
await User.findByIdAndUpdate(refreshToken.userId, { lastOpenedAt: now });
console.log(`[app-open] token-refresh: userId=${refreshToken.userId} lastOpenedAt=${now.toISOString()}`);
} catch (err) {
console.log(`Failed to update lastOpenedAt on token refresh for userId ${refreshToken.userId}: ${err.message}`);
// Don't fail the token-refresh if the activity update fails
}

return res.status(200).json({ token });
};
16 changes: 10 additions & 6 deletions src/routes/auth/google-sign-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,32 @@ module.exports = async (req, res) => {
const [firstName, lastName] = name.split(" ");

let user = await User.findOne({ email });
const now = new Date();
if (!user) {
user = new User({
email: email,
firstName: firstName || name,
lastName: lastName || "",
createdAt: new Date(),
createdAt: now,
avatar: picture,
lastLogin: new Date(),
lastLogin: now,
lastOpenedAt: now,
});
await user.save();
console.log(`[app-open] google-sign-in (new): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
} else {
// Check if user is archived
if (user.isArchived) {
return res.status(403).json({
return res.status(403).json({
error: "Account archived",
isArchived: true,
userId: user._id.toString()
});
}

// Update lastLogin for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: new Date() });

// Real app-open: update both lastLogin and lastOpenedAt for existing users
await User.findByIdAndUpdate(user._id, { lastLogin: now, lastOpenedAt: now });
console.log(`[app-open] google-sign-in (existing): userId=${user._id} lastOpenedAt=${now.toISOString()}`);
}

const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
Expand Down
12 changes: 8 additions & 4 deletions src/routes/auth/sign-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ module.exports = async (req, res, next) => {

const userId = user.id;

// Update lastLogin timestamp
// Real app-open event: set both lastLogin and lastOpenedAt.
// lastOpenedAt powers the new user-status pipeline (replacing lastActivityTime).
// ONLY set here in real auth flows — never from middleware/syncs/admin scripts.
const now = new Date();
try {
await User.findByIdAndUpdate(userId, { lastLogin: new Date() });
await User.findByIdAndUpdate(userId, { lastLogin: now, lastOpenedAt: now });
console.log(`[app-open] sign-in: userId=${userId} lastOpenedAt=${now.toISOString()}`);
} catch (updateErr) {
console.log(`Failed to update lastLogin for userId ${userId}: ${updateErr.message}`);
// Continue with login even if lastLogin update fails
console.log(`Failed to update lastLogin/lastOpenedAt for userId ${userId}: ${updateErr.message}`);
// Continue with login even if the update fails
}

const today = moment.utc();
Expand Down
Loading