Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add Badge api #357

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion docker-compose.yml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: why is this change needed?

Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:
ELASTICSEARCH_URL: "http://db:9200"
URL_RESOLVER_URL: "url-resolver:4000"
ports:
- "5000:5000"
- "6000:5000"
- "5500:5500"

site:
Expand Down
38 changes: 38 additions & 0 deletions src/adm/handlers/moderation/__fixtures__/awardBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { User } from 'rumors-db/schema/users';
import type { Badge } from 'rumors-db/schema/badges';

export default {
'/users/doc/user-to-award-badge': {
name: 'user-to-award-badge',
createdAt: '2020-01-01T00:00:00.000Z',
googleId: 'some-google-id',
badges: [],
} satisfies User,

'/users/doc/user-already-award-badge': {
name: 'user-already-award-badge',
createdAt: '2020-01-01T00:00:00.000Z',
googleId: 'some-google-id',
badges: [
{
badgeId: 'test-certification-001',
badgeMetaData: '{"from":"some-orgnization"}',
isDisplayed: false,
createdAt: '2020-01-01T00:00:00.000Z',
updatedAt: '2020-01-01T00:00:00.000Z',
},
],
} satisfies User,

'/badges/doc/test-certification-001': {
name: 'Test Certification',
displayName: 'Test Certification',
description: 'A test certification badge',
link: 'https://badge.source.com',
icon: 'https://badge.source.com/icon.png',
borderImage: 'https://badge.source.com/border.png',
issuers: ['[email protected]', 'service-token-123'],
createdAt: '2020-01-01T00:00:00.000Z',
updatedAt: '2020-01-01T00:00:00.000Z',
} satisfies Badge,
};
110 changes: 110 additions & 0 deletions src/adm/handlers/moderation/__tests__/awardBadge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import MockDate from 'mockdate';

import { loadFixtures, unloadFixtures } from 'util/fixtures';
import client from 'util/client';
import awardBadge from '../awardBadge';
import fixtures from '../__fixtures__/awardBadge';

const FIXED_DATE = 612921600000;

beforeEach(async () => {
await loadFixtures(fixtures);
MockDate.set(FIXED_DATE);
});

afterEach(async () => {
await unloadFixtures(fixtures);
MockDate.reset();
});

describe('awardBadge', () => {
it('fails if userId is not valid', async () => {
await expect(
awardBadge({
userId: 'not-exist',
badgeId: 'badge id',
badgeMetaData: '{}',
request: { userId: '[email protected]' },
})
).rejects.toMatchInlineSnapshot(
`[HTTPError: User with ID=not-exist does not exist]`
);
});

/**
* Asserts the document in database is the same as in the fixture,
* i.e. the document is not modified
*
* @param {string} fixtureKey
* @param {{index: string; id: string;}} clientGetArgs - Arguments for client.get()
*/

it('correctly sets the awarded badge id when authorized', async () => {
const result = await awardBadge({
userId: 'user-to-award-badge',
badgeId: 'test-certification-001',
badgeMetaData: '{"from":"some-orgnization"}',
request: { userId: '[email protected]' },
});

expect(result).toMatchInlineSnapshot(`
Object {
"badgeId": "test-certification-001",
"badgeMetaData": "{\\"from\\":\\"some-orgnization\\"}",
}
`);

const {
body: { _source: userWithBadge },
} = await client.get({
index: 'users',
type: 'doc',
id: 'user-to-award-badge',
});

// Assert that badgeId is written on the user
expect(userWithBadge).toMatchInlineSnapshot(`
Object {
"badges": Array [
Object {
"badgeId": "test-certification-001",
"badgeMetaData": "{\\"from\\":\\"some-orgnization\\"}",
"createdAt": "1989-06-04T00:00:00.000Z",
"isDisplayed": true,
"updatedAt": "1989-06-04T00:00:00.000Z",
},
],
"createdAt": "2020-01-01T00:00:00.000Z",
"googleId": "some-google-id",
"name": "user-to-award-badge",
}
`);
});

it('allows service token to award badge', async () => {
const result = await awardBadge({
userId: 'user-to-award-badge',
badgeId: 'test-certification-001',
badgeMetaData: '{"from":"service"}',
request: { userId: 'service-token-123' },
});

expect(result).toMatchInlineSnapshot(`
Object {
"badgeId": "test-certification-001",
"badgeMetaData": "{\\"from\\":\\"service\\"}",
}
`);

const {
body: { _source: userWithBadge },
} = await client.get({
index: 'users',
type: 'doc',
id: 'user-to-award-badge',
});

expect(userWithBadge.badges).toHaveLength(1);
expect(userWithBadge.badges[0].badgeId).toBe('test-certification-001');
});
});
146 changes: 146 additions & 0 deletions src/adm/handlers/moderation/awardBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Given userId & award badge (Id and metadata).
*
*/
import { HTTPError } from 'fets';

import client from 'util/client';

/**
* Update user to write badgeId. Throws if user does not exist.
*
* @param userId
* @param badgeId
* @param badgeMetaData
*/
async function appendBadgeToList(
userId: string,
badgeId: string,
badgeMetaData: string
) {
const now = new Date().toISOString();

try {
const {
body: { result: setbadgeIdResult },
} = await client.update({
index: 'users',
type: 'doc',
id: userId,
body: {
script: {
source: `
if (ctx._source.badges == null) {
ctx._source.badges = [];
}
ctx._source.badges.add(params.badge);
`,
params: {
badge: {
badgeId: badgeId,
badgeMetaData: badgeMetaData,
createdAt: now,
isDisplayed: true,
updatedAt: now,
},
},
},
},
});

/* istanbul ignore if */
if (setbadgeIdResult === 'noop') {
console.log(`Info: user ID ${userId} already has set the same badgeId.`);
}
} catch (e) {
console.log(e);
/* istanbul ignore else */
if (
e &&
typeof e === 'object' &&
'message' in e &&
e.message === 'document_missing_exception'
) {
throw new HTTPError(400, `User with ID=${userId} does not exist`);
}

throw e;
}
}

/**
* Verify if the badge exists and if the current user is authorized to issue it
*
* @param badgeId - ID of the badge to verify
* @param requestUserId - ID of the user making the request
* @throws {HTTPError} if badge doesn't exist or user is not authorized
*/
async function verifyBadgeIssuer(badgeId: string, requestUserId: string) {
try {
const {
body: { _source: badge },
} = await client.get({
index: 'badges',
type: 'doc',
id: badgeId,
});

if (!badge) {
throw new HTTPError(404, `Badge with ID=${badgeId} does not exist`);
}

if (!badge.issuers?.includes(requestUserId)) {
throw new HTTPError(
403,
`User ${requestUserId} is not authorized to issue badge ${badgeId}`
);
}
} catch (e) {
if (e instanceof HTTPError) throw e;
throw new HTTPError(404, `Badge with ID=${badgeId} does not exist`);
}
}

type awardBadgeReturnValue = {
badgeId: string;
badgeMetaData: string;
};

async function main({
userId,
badgeId,
badgeMetaData,
request,
}: {
userId: string;
badgeId: string;
badgeMetaData: string;
request: { userId: string };
}): Promise<awardBadgeReturnValue> {
// Check if user exists first
try {
const { body } = await client.get({
index: 'users',
type: 'doc',
id: userId,
});
if (!body._source) {
throw new HTTPError(400, `User with ID=${userId} does not exist`);
}
} catch (e) {
if (e instanceof HTTPError) throw e;
throw new HTTPError(400, `User with ID=${userId} does not exist`);
}

// Verify if the current user/service is authorized to issue this badge
await verifyBadgeIssuer(badgeId, request.userId);

await appendBadgeToList(userId, badgeId, badgeMetaData);

return {
badgeId,
badgeMetaData,
};
}

export default main;
43 changes: 41 additions & 2 deletions src/adm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useAuditLog, useAuth } from './util';

import pingHandler from './handlers/ping';
import blockUser from './handlers/moderation/blockUser';
import awardBadge from './handlers/moderation/awardBadge';

const shouldAuth = process.env.NODE_ENV === 'production';

Expand Down Expand Up @@ -49,7 +50,7 @@ const router = createRouter({
),
},
},
handler: async (request) =>
handler: async (request: Request) =>
Response.json(pingHandler(await request.json())),
})
.route({
Expand Down Expand Up @@ -80,8 +81,46 @@ const router = createRouter({
}),
},
},
handler: async (request) =>
handler: async (request: Request) =>
Response.json(await blockUser(await request.json())),
})
.route({
method: 'POST',
path: '/moderation/awardBadge',
description: 'Award the badge to the specified user.',
schemas: {
request: {
json: Type.Object(
{
userId: Type.String({
description: 'The user ID',
}),
badgeId: Type.String({
description: 'The badge key',
}),
badgeMetaData: Type.String({
description: 'The badge metadata, json in string format',
}),
},
{ additionalProperties: false }
),
},
responses: {
200: Type.Object({
badgeId: Type.String(),
badgeMetaData: Type.String(),
}),
},
},
handler: async (request: Request) => {
const body = await request.json();
return Response.json(
await awardBadge({
...body,
request, // Pass the entire request object from feTS
})
);
},
});

createServer(router).listen(process.env.ADM_PORT, () => {
Expand Down
27 changes: 27 additions & 0 deletions src/graphql/dataLoaders/badgeLoaderFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import DataLoader from 'dataloader';
import client, { processMeta } from 'util/client';

export default () =>
new DataLoader(async (slugs) => {
const body = [];

slugs.forEach(({ slug }) => {
body.push({ index: 'badges', type: 'doc' });

body.push({
query: {
term: { slug },
},
size: 1,
});
});

return (
await client.msearch({
body,
})
).body.responses.map(({ hits }) => {
if (!hits || !hits.hits || hits.hits.length == 0) return null;
return processMeta(hits.hits[0]);
});
});
Loading
Loading