Skip to content

Commit

Permalink
feat(syncs): show syncs a user can see
Browse files Browse the repository at this point in the history
based on their project membership
  • Loading branch information
coderbyheart committed Sep 17, 2023
1 parent 9b91488 commit 057036b
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 38 deletions.
2 changes: 1 addition & 1 deletion cdk/constructs/RESTAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export class RESTAPI extends Construct {
listSyncs: {
routeKey: 'GET /syncs',
source: lambdaSources.listSyncs,
description: 'Lists syncs a user has created',
description: 'Lists syncs a user is participating in',
authContext: 'user',
},
}
Expand Down
23 changes: 23 additions & 0 deletions core/persistence/createSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ export const createSync =
ConditionExpression: 'attribute_not_exists(id)',
}),
)

// Store all related projects
await Promise.all(
[...projectIds].map((projectId) =>
db.send(
new PutItemCommand({
TableName,
Item: {
id: {
S: id,
},
type: {
S: `project|${projectId}`,
},
sync__project: {
S: l(projectId),
},
},
}),
),
),
)

const event: SyncCreatedEvent = {
type: CoreEventType.SYNC_CREATED,
title,
Expand Down
1 change: 1 addition & 0 deletions core/persistence/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const indexes: Record<
syncOwner: {
keys: ['sync__owner', 'id'],
},
projectSyncProject: { keys: ['sync__project', 'id'] },
invitationsForUser: {
keys: ['projectInvitation__invitee', 'id'],
include: ['role', 'projectInvitation__inviter'],
Expand Down
3 changes: 3 additions & 0 deletions core/persistence/deleteSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export const deleteSync =
timestamp: new Date(),
}
await notify(event)

// FIXME: Delete project index

return { deleted: true }
} catch (error) {
if ((error as Error).name === ConditionalCheckFailedException.name)
Expand Down
96 changes: 75 additions & 21 deletions core/persistence/listSyncs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,32 @@ import type { UserAuthContext } from '../auth.js'
import { type DbContext } from './DbContext.js'
import { itemToSync, serialize, type SerializedSync } from './getSync.js'
import { l } from './l.js'
import { listProjects } from './listProjects.js'

export const listSyncs =
(dbContext: DbContext) =>
async (
authContext: UserAuthContext,
): Promise<{ error: ProblemDetail } | { syncs: SerializedSync[] }> => {
const { sub: userId } = authContext
const ownerSyncs = await getSyncsByOwnerRole(dbContext, authContext)
const projectSyncs = await getSyncsByProjectMembers(dbContext, authContext)
const allSyncs = [...new Set([...ownerSyncs, ...projectSyncs])]

const { db, TableName } = dbContext
const { Items } = await db.send(
new QueryCommand({
TableName,
IndexName: 'syncOwner',
KeyConditionExpression: '#owner = :user',
ExpressionAttributeNames: {
'#id': 'id',
'#owner': 'sync__owner',
},
ExpressionAttributeValues: {
':user': {
S: l(userId),
},
},
ProjectionExpression: '#id',
}),
)
// FIXME: add pagination
const ids = allSyncs
// only show syncs of the last 30 days
.filter((id) => Date.now() - decodeTime(id) < 30 * 24 * 60 * 60 * 1000)
.sort((a, b) => b.localeCompare(a))
.slice(0, 25)

if (Items === undefined || Items.length === 0) return { syncs: [] }
if (ids.length === 0) return { syncs: [] }

const { db, TableName } = dbContext
const { Responses } = await db.send(
new BatchGetItemCommand({
RequestItems: {
[TableName]: {
Keys: Items.map((Item) => unmarshall(Item)).map(({ id }) => ({
Keys: ids.map((id) => ({
id: { S: id },
type: {
S: 'projectSync',
Expand All @@ -57,3 +49,65 @@ export const listSyncs =
.map(serialize),
}
}

const getSyncsByOwnerRole = async (
dbContext: DbContext,
authContext: UserAuthContext,
): Promise<string[]> => {
const { db, TableName } = dbContext
const { sub: userId } = authContext
const { Items } = await db.send(
new QueryCommand({
TableName,
IndexName: 'syncOwner',
KeyConditionExpression: '#owner = :user',
ExpressionAttributeNames: {
'#id': 'id',
'#owner': 'sync__owner',
},
ExpressionAttributeValues: {
':user': {
S: l(userId),
},
},
ProjectionExpression: '#id',
}),
)

return (Items ?? []).map((item) => unmarshall(item)).map(({ id }) => id)
}

const getSyncsByProjectMembers = async (
dbContext: DbContext,
authContext: UserAuthContext,
): Promise<string[]> => {
const projects = await listProjects(dbContext)(authContext)
if ('error' in projects) return []

const { db, TableName } = dbContext
const res = await Promise.all(
projects.projects.map(({ id }) =>
db.send(
new QueryCommand({
TableName,
IndexName: 'projectSyncProject',
KeyConditionExpression: '#project = :project',
ExpressionAttributeNames: {
'#id': 'id',
'#project': 'sync__project',
},
ExpressionAttributeValues: {
':project': {
S: l(id),
},
},
ProjectionExpression: '#id',
}),
),
),
)

return res.flatMap(({ Items }) =>
(Items ?? []).map((Item) => unmarshall(Item)).map(({ id }) => id),
)
}
100 changes: 84 additions & 16 deletions core/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,23 +183,91 @@ describe('sync', async () => {
})
})

it('should list syncs', async () => {
const { syncs } = (await listSyncs(dbContext)(user)) as {
syncs: SerializedSync[]
}
describe('list syncs', () => {
it('should list syncs owned by the user', async () => {
const { syncs } = (await listSyncs(dbContext)(user)) as {
syncs: SerializedSync[]
}

check(syncs?.[0]).is(
objectMatching({
id: aString,
title: 'My sync',
owner: user.sub,
}),
)
check(syncs?.[0]?.projectIds.sort((a, b) => a.localeCompare(b))).is(
arrayMatching(
[projectA, projectB].sort((a, b) => a.localeCompare(b)).map(l),
),
)
check(syncs?.[0]).is(
objectMatching({
id: aString,
title: 'My sync',
owner: user.sub,
}),
)
check(syncs?.[0]?.projectIds.sort((a, b) => a.localeCompare(b))).is(
arrayMatching(
[projectA, projectB].sort((a, b) => a.localeCompare(b)).map(l),
),
)
})

it('should list syncs that the user has access to', async () => {
const organizationId = `$test-user-sync-${ulid()}`
const projectA = `${organizationId}#test-${ulid()}`
const projectB = `${organizationId}#test-${ulid()}`
const projectC = `${organizationId}#test-${ulid()}`
const projectIds = [projectA, projectB, projectC]

// This user will be invited to the projects as a member and should see the syncs
const jo: UserAuthContext = {
email: '[email protected]',
sub: '@jo',
}

// Create the organization
isNotAnError(
await createOrganization(dbContext, notify)(
{
id: organizationId,
name: `Organization ${organizationId}`,
},
user,
),
)

// Create the projects
const syncIds: string[] = []
for (const projectId of projectIds) {
// Create the project
isNotAnError(
await createProject(dbContext, notify)(
{
id: projectId,
name: `Project ${projectId}`,
},
user,
),
)
// Create a sync
const id = ulid()
syncIds.push(id)
isNotAnError(
await createSync(dbContext, notify)(
{
id,
projectIds: new Set([projectId]),
},
user,
),
)
// Create a member
await createProjectMember(dbContext, notify)(
projectId,
jo.sub,
Role.WATCHER,
)
}
eventually(async () => {
const { syncs } = (await listSyncs(dbContext)(jo)) as {
syncs: SerializedSync[]
}
for (const syncId of syncIds) {
check(syncs).is(arrayContaining(objectMatching({ id: syncId })))
}
})
})
})

describe('accessing syncs', async () => {
Expand Down

0 comments on commit 057036b

Please sign in to comment.