Skip to content
Merged
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
17 changes: 16 additions & 1 deletion firestore.indexes.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
{
"indexes": [],
"fieldOverrides": []
"fieldOverrides": [
{
"collectionGroup": "joinCodes",
"fieldPath": "codeHash",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
}
]
}
63 changes: 54 additions & 9 deletions functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,22 +117,47 @@ async function requireScopedTeamAdmin(req, requestedTeamId, deps = {}) {

function createJoinCodeDoc(dbClient, batch, teamId, { seatId, seatName }) {
const joinCode = generateJoinCode();
const codeHash = sha256(joinCode);
const joinCodeRef = dbClient.doc(`teams/${teamId}/joinCodes/${uuidv4()}`);
batch.set(joinCodeRef, {
codeHash: sha256(joinCode),
const joinCodeDoc = {
codeHash,
role: "member",
uses: 0,
createdAt: FieldValue.serverTimestamp(),
createdBySeatId: seatId,
createdBySeatName: seatName,
});
};

batch.set(joinCodeRef, joinCodeDoc);
batch.set(joinCodeLookupRef(dbClient, codeHash), buildJoinCodeLookupDoc({
teamId,
joinCodeRef,
role: joinCodeDoc.role,
createdBySeatId: seatId,
createdBySeatName: seatName,
}));

return {
joinCode,
joinCodeRef,
};
}

function joinCodeLookupRef(dbClient, codeHash) {
return dbClient.doc(`joinCodeLookups/${codeHash}`);
}

function buildJoinCodeLookupDoc({ teamId, joinCodeRef, role, createdBySeatId, createdBySeatName }) {
return {
teamId,
joinCodePath: joinCodeRef.path,
role,
createdAt: FieldValue.serverTimestamp(),
createdBySeatId,
createdBySeatName,
};
}

async function issueJoinCodeForTeam({ dbClient = db, teamId, seatId, seatName }) {
const batch = dbClient.batch();
const { joinCode, joinCodeRef } = createJoinCodeDoc(dbClient, batch, teamId, {
Expand Down Expand Up @@ -244,14 +269,21 @@ async function createTeamReport({

async function joinTeamWithCode({ dbClient = db, adminClient = admin, joinCode, seatName }) {
const codeHash = sha256(joinCode);
const lookupRef = joinCodeLookupRef(dbClient, codeHash);
const lookupSnap = await lookupRef.get();
const lookup = lookupSnap.exists ? lookupSnap.data() : null;

const codesSnap = await dbClient
.collectionGroup("joinCodes")
.where("codeHash", "==", codeHash)
.limit(1)
.get();
let joinCodeRef = lookup?.joinCodePath ? dbClient.doc(lookup.joinCodePath) : null;

if (!joinCodeRef) {
const codesSnap = await dbClient
.collectionGroup("joinCodes")
.where("codeHash", "==", codeHash)
.limit(1)
.get();
joinCodeRef = codesSnap.empty ? null : codesSnap.docs[0].ref;
}

const joinCodeRef = codesSnap.empty ? null : codesSnap.docs[0].ref;
const teamId = joinCodeRef ? joinCodeRef.parent.parent.id : null;

if (!joinCodeRef) {
Expand All @@ -262,6 +294,12 @@ async function joinTeamWithCode({ dbClient = db, adminClient = admin, joinCode,

const result = await dbClient.runTransaction(async (tx) => {
const freshSnap = await tx.get(joinCodeRef);
if (!freshSnap.exists) {
const err = new Error("Invalid join code");
err.status = 404;
throw err;
}

const data = freshSnap.data();

if (data.expiresAt && data.expiresAt.toDate() < new Date()) {
Expand All @@ -281,6 +319,13 @@ async function joinTeamWithCode({ dbClient = db, adminClient = admin, joinCode,
const now = FieldValue.serverTimestamp();

tx.update(joinCodeRef, { uses: (data.uses || 0) + 1 });
tx.set(lookupRef, buildJoinCodeLookupDoc({
teamId,
joinCodeRef,
role: data.role || "member",
createdBySeatId: data.createdBySeatId || null,
createdBySeatName: data.createdBySeatName || null,
}), { merge: true });

tx.set(dbClient.doc(`teams/${teamId}/memberships/${seatId}`), {
role: data.role || "member",
Expand Down
48 changes: 47 additions & 1 deletion test/join-flow-api.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,13 @@ test('issueJoinCodeForTeam always stores member invites', async () => {
const stored = db._store.get(`teams/team-1/joinCodes/${result.joinCodeId}`);
assert.equal(stored.role, 'member');
assert.equal(stored.createdBySeatName, 'Admin Seat');

const lookup = db._store.get(`joinCodeLookups/${stored.codeHash}`);
assert.equal(lookup.teamId, 'team-1');
assert.equal(lookup.joinCodePath, `teams/team-1/joinCodes/${result.joinCodeId}`);
});

test('joinTeamWithCode lands the new seat on the same team', async () => {
test('joinTeamWithCode lands the new seat on the same team and backfills lookup docs for legacy codes', async () => {
const db = createMemoryDb({
'teams/team-1/joinCodes/code-1': {
codeHash: sha256('SP7E-YKDH-LPC3'),
Expand Down Expand Up @@ -229,6 +233,48 @@ test('joinTeamWithCode lands the new seat on the same team', async () => {
const seatEntries = [...db._store.entries()].filter(([path]) => path.startsWith('seats/'));
assert.equal(seatEntries.length, 1);
assert.equal(seatEntries[0][1].homeTeamId, 'team-1');

const lookup = db._store.get(`joinCodeLookups/${sha256('SP7E-YKDH-LPC3')}`);
assert.equal(lookup.teamId, 'team-1');
assert.equal(lookup.joinCodePath, 'teams/team-1/joinCodes/code-1');
});

test('joinTeamWithCode prefers the top-level lookup over collection-group search', async () => {
const joinCode = 'SP7E-YKDH-LPC3';
const codeHash = sha256(joinCode);
const db = createMemoryDb({
'joinCodeLookups/55a0a645-should-not-be-used': {
teamId: 'team-other',
joinCodePath: 'teams/team-other/joinCodes/code-other',
},
[`joinCodeLookups/${codeHash}`]: {
teamId: 'team-1',
joinCodePath: 'teams/team-1/joinCodes/code-1',
role: 'member',
},
'teams/team-1/joinCodes/code-1': {
codeHash,
role: 'member',
uses: 0,
createdBySeatId: 'seat-admin',
createdBySeatName: 'Admin Seat',
},
});

db.collectionGroup = () => ({
where() {
throw new Error('collectionGroup lookup should not run when a lookup doc exists');
},
});

const result = await joinTeamWithCode({
dbClient: db,
adminClient: createAdminClient({ customTokenPrefix: 'joined' }),
joinCode,
seatName: 'Lookup Path Seat',
});

assert.equal(result.teamId, 'team-1');
});

test('createTeamReport stores bug reports with seat metadata', async () => {
Expand Down
Loading