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

Choose a reason for hiding this comment

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

Forgot to ask: what is this for?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I should revert this one, I found that Mac default use 5000 port for airplay receiver

- "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,
};
102 changes: 102 additions & 0 deletions src/adm/handlers/moderation/__tests__/awardBadge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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]`
);
});

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.`);
Copy link
Member

Choose a reason for hiding this comment

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

It seems that the script above do not check if the same badge has been awarded or not, and always performs ctx._source.badges.add.
In this case, this console.log here would never be executed.

}
} 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
Copy link
Member

Choose a reason for hiding this comment

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

Please remove this unused loader implementation

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