인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import {
|
||||
@@ -989,7 +989,7 @@ export const generalRouter = router({
|
||||
|
||||
const now = new Date();
|
||||
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
||||
const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([
|
||||
const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
where: {
|
||||
lastActionAt: {
|
||||
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
|
||||
},
|
||||
}),
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief
|
||||
? ctx.db.general.findMany({
|
||||
where: {
|
||||
nationId: me.nationId,
|
||||
meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' },
|
||||
},
|
||||
select: { id: true, userId: true, meta: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
|
||||
|
||||
return {
|
||||
serverId,
|
||||
cityOfficeRequests: {
|
||||
generalId: me.id,
|
||||
nationId: me.nationId,
|
||||
ids: officeRequestRows.flatMap((row) => {
|
||||
const request = readOfficeRequest(row.meta);
|
||||
return request?.status === 'pending' &&
|
||||
request.generalId === row.id &&
|
||||
request.nationId === me.nationId &&
|
||||
request.userId === row.userId
|
||||
? [request.id]
|
||||
: [];
|
||||
}),
|
||||
},
|
||||
onlineUserCount: onlineGenerals.length,
|
||||
onlineNations,
|
||||
onlineGenerals: myOnlineGenerals,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
asRecord,
|
||||
GAME_TICKS_PER_TURN,
|
||||
officeRequestQuarter,
|
||||
readOfficeRequest,
|
||||
type CityOfficeLevel,
|
||||
} from '@sammo-ts/common';
|
||||
import { resolveOfficeRequestEligibility, type OfficeCandidate } from '@sammo-ts/logic';
|
||||
import { accessAuthedProcedure, engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||
import { assertNationAccess, resolveChiefStatMin } from '../shared.js';
|
||||
import { loadCurrentGameTime } from '../../../services/gameClock.js';
|
||||
|
||||
export const cityOfficeRequest = engineAuthedProcedure
|
||||
.input(
|
||||
z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('request'),
|
||||
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(['approve', 'reject', 'withdraw']),
|
||||
targetGeneralId: z.number().int().positive(),
|
||||
officeRequestId: z.string().min(1),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
...input,
|
||||
type: 'cityOfficeRequest',
|
||||
userId: getAuthenticatedUserId(ctx),
|
||||
generalId: general.id,
|
||||
...(ctx.requestId
|
||||
? { requestId: `${ctx.requestId}:nation.cityOfficeRequest:engine:0:cityOfficeRequest` }
|
||||
: {}),
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'cityOfficeRequest')
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
if (!result.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
export const getCityOfficeRequests = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const [nation, cities, rows, state, clock] = await Promise.all([
|
||||
ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true } }),
|
||||
ctx.db.city.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: { id: true, nationId: true, name: true, meta: true },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
officerLevel: true,
|
||||
npcState: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
if (!nation || !state)
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가 또는 게임 상태를 찾을 수 없습니다.' });
|
||||
const candidates = rows.map((row) => ({ ...row, intelligence: row.intel }));
|
||||
const applicant: OfficeCandidate = { ...me, intelligence: me.intel };
|
||||
const city = cities.find((entry) => entry.id === me.cityId) ?? null;
|
||||
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||
const own = readOfficeRequest(me.meta);
|
||||
const requests = candidates.flatMap((general) => {
|
||||
const request = readOfficeRequest(general.meta);
|
||||
if (
|
||||
!request ||
|
||||
request.generalId !== general.id ||
|
||||
request.nationId !== me.nationId ||
|
||||
request.userId !== general.userId
|
||||
)
|
||||
return [];
|
||||
return [{ general, request }];
|
||||
});
|
||||
const evaluate = (general: OfficeCandidate, cityId: number, level: CityOfficeLevel) =>
|
||||
resolveOfficeRequestEligibility({
|
||||
applicant: general,
|
||||
city: cities.find((entry) => entry.id === cityId) ?? null,
|
||||
level,
|
||||
incumbent:
|
||||
candidates.find(
|
||||
(entry) =>
|
||||
entry.officerLevel === level &&
|
||||
Number(asRecord(entry.meta).officerCity ?? asRecord(entry.meta).officer_city ?? 0) === cityId
|
||||
) ?? null,
|
||||
nationMeta: nation.meta,
|
||||
chiefStatMin: resolveChiefStatMin(state),
|
||||
});
|
||||
const canManage = me.officerLevel >= 5 && !asRecord(me.penalty).noChief;
|
||||
const nowTick = clock.tick;
|
||||
const options = ([4, 3, 2] as const).map((level) => {
|
||||
const check = evaluate(applicant, me.cityId, level);
|
||||
const occupied = requests.some(
|
||||
({ request }) =>
|
||||
request.status === 'pending' && request.cityId === me.cityId && request.officerLevel === level
|
||||
);
|
||||
const reason =
|
||||
check.reason ??
|
||||
(own?.status === 'pending'
|
||||
? '요청 대기 중'
|
||||
: own?.quarter === quarter
|
||||
? '이번 분기 자원 완료'
|
||||
: occupied
|
||||
? '다른 장수 자원 중'
|
||||
: nowTick === null
|
||||
? '게임 시계 준비 중'
|
||||
: null);
|
||||
return { level, ...check, allowed: !reason, reason };
|
||||
});
|
||||
return {
|
||||
canManage,
|
||||
generalId: me.id,
|
||||
nationId: me.nationId,
|
||||
serverId: String(asRecord(state.meta).serverId ?? ctx.profile?.name ?? 'game'),
|
||||
city: city ? { id: city.id, name: city.name } : null,
|
||||
options,
|
||||
running: clock.running,
|
||||
tickSeconds: state.tickSeconds,
|
||||
requests: requests
|
||||
.filter(({ general, request }) => general.id === me.id || (canManage && request.status === 'pending'))
|
||||
.map(({ general, request }) => {
|
||||
const check = evaluate(general, request.cityId, request.officerLevel);
|
||||
const defaultDecision =
|
||||
request.defaultDecision === 'reject' || check.defaultDecision === 'reject'
|
||||
? ('reject' as const)
|
||||
: ('approve' as const);
|
||||
const months = Math.max(
|
||||
0,
|
||||
Math.ceil((request.dueTick - Number(state.lastTurnTick ?? 0)) / GAME_TICKS_PER_TURN)
|
||||
);
|
||||
const monthIndex = state.currentYear * 12 + state.currentMonth - 1 + months;
|
||||
return {
|
||||
id: request.id,
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
cityId: request.cityId,
|
||||
cityName: cities.find((entry) => entry.id === request.cityId)?.name ?? '-',
|
||||
officerLevel: request.officerLevel,
|
||||
status: request.status,
|
||||
defaultDecision,
|
||||
defaultReason: check.defaultReason === 'secret' ? ('secret' as const) : request.defaultReason,
|
||||
resultReason: request.resultReason ?? null,
|
||||
dueYear: Math.floor(monthIndex / 12),
|
||||
dueMonth: (monthIndex % 12) + 1,
|
||||
remainingSeconds:
|
||||
nowTick === null
|
||||
? null
|
||||
: Math.max(0, ((request.dueTick - nowTick) / GAME_TICKS_PER_TURN) * state.tickSeconds),
|
||||
invalidReason: request.status === 'pending' ? check.reason : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js';
|
||||
import { router } from '../../trpc.js';
|
||||
import { appoint } from './endpoints/appoint.js';
|
||||
import { changePermission } from './endpoints/changePermission.js';
|
||||
@@ -25,6 +26,8 @@ export const nationRouter = router({
|
||||
getSecretGeneralList,
|
||||
getCityOverview,
|
||||
getPersonnelInfo,
|
||||
getCityOfficeRequests,
|
||||
cityOfficeRequest,
|
||||
getStratFinan,
|
||||
setNotice,
|
||||
setScoutMsg,
|
||||
|
||||
@@ -59,6 +59,7 @@ const classifications = {
|
||||
'join.reselectPoolGeneral',
|
||||
'join.selectPoolGeneral',
|
||||
'nation.appoint',
|
||||
'nation.cityOfficeRequest',
|
||||
'nation.changePermission',
|
||||
'nation.kick',
|
||||
'nation.setBill',
|
||||
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
|
||||
// count independently catches mutations that were added to a router but never mounted.
|
||||
expect(declaredCount).toBe(actual.length);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
expect(classified).toHaveLength(88);
|
||||
expect(actual).toEqual(classified);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
|
||||
};
|
||||
|
||||
const buildContext = (
|
||||
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {}
|
||||
options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
hasVoted?: boolean;
|
||||
preopenClock?: boolean;
|
||||
officerLevel?: number;
|
||||
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
|
||||
} = {}
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
@@ -30,14 +36,19 @@ const buildContext = (
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'owner',
|
||||
officerLevel: options.officerLevel ?? 1,
|
||||
nationId: 2,
|
||||
})),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]),
|
||||
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
|
||||
input.where?.meta
|
||||
? (options.officeRows ?? [])
|
||||
: [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]
|
||||
),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: 'che_260819_front',
|
||||
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
|
||||
onlineUserCount: 4,
|
||||
onlineNations: '【촉】, 【위】',
|
||||
onlineGenerals: '유비, 관우',
|
||||
@@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => {
|
||||
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
});
|
||||
|
||||
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
|
||||
const pending = {
|
||||
id: '8:760',
|
||||
generalId: 8,
|
||||
userId: 'user-8',
|
||||
nationId: 2,
|
||||
cityId: 1,
|
||||
officerLevel: 4,
|
||||
incumbentId: 0,
|
||||
quarter: 760,
|
||||
createdTick: 0,
|
||||
dueTick: 3600000,
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
status: 'pending',
|
||||
};
|
||||
const officeRows = [
|
||||
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
|
||||
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
|
||||
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
|
||||
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
|
||||
];
|
||||
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
|
||||
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
|
||||
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
|
||||
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
|
||||
|
||||
const expectedOwnerCounts: Record<string, number> = {
|
||||
'durable-journal': 19,
|
||||
'engine-owned': 38,
|
||||
'engine-owned': 39,
|
||||
'explicit-no-realtime-consumer': 7,
|
||||
'external-upload': 1,
|
||||
'mixed-saga': 9,
|
||||
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
|
||||
const rows = parseManifest();
|
||||
const manifestRoutes = rows.map(({ route }) => route);
|
||||
|
||||
expect(rows).toHaveLength(88);
|
||||
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
|
||||
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
|
||||
expect(manifestRoutes).toEqual(mountedMutationNames());
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { once } from 'node:events';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
|
||||
expect(memberCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('city office requests', () => {
|
||||
it.each(['approve', 'reject', 'withdraw'] as const)(
|
||||
'binds %s to the session actor and durable request ID',
|
||||
async (action) => {
|
||||
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
|
||||
await expect(
|
||||
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'cityOfficeRequest',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
|
||||
action,
|
||||
targetGeneralId: 30,
|
||||
officeRequestId: '30:760',
|
||||
});
|
||||
}
|
||||
);
|
||||
it('validates request fields and propagates engine rejection', async () => {
|
||||
const requestCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
|
||||
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
|
||||
message: '이번 분기 자원 완료',
|
||||
});
|
||||
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
|
||||
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(requestCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
|
||||
const office = {
|
||||
id: '30:760',
|
||||
generalId: 30,
|
||||
userId: 'user-30',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
officerLevel: 4,
|
||||
incumbentId: 0,
|
||||
quarter: 760,
|
||||
createdTick: 0,
|
||||
dueTick: 3600000,
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
status: 'pending',
|
||||
};
|
||||
const me = { ...baseGeneral, officerLevel };
|
||||
const caller = appRouter.createCaller(
|
||||
createContext({
|
||||
me,
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findMany: vi.fn(async () => [
|
||||
me,
|
||||
{
|
||||
...baseGeneral,
|
||||
id: 30,
|
||||
userId: 'user-30',
|
||||
officerLevel: 1,
|
||||
meta: { belong: 5, cityOfficeRequest: office },
|
||||
},
|
||||
]),
|
||||
},
|
||||
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
meta: {},
|
||||
config: { stat: { chiefMin: 65 } },
|
||||
clockBaseTime: new Date('2026-01-01'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01'),
|
||||
clockPhase: 'RUNNING',
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await caller.nation.getCityOfficeRequests();
|
||||
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
|
||||
expect(JSON.stringify(result)).not.toContain('user-30');
|
||||
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
|
||||
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext: ({ req }): GameApiContext => ({
|
||||
...createContext({ requestId: 'office-http-wire', requestCommand }),
|
||||
auth: req.headers['x-test-session'] ? auth : null,
|
||||
}),
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address() as AddressInfo;
|
||||
const send = (body: unknown, authenticated = true) =>
|
||||
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
|
||||
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
|
||||
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
|
||||
requestCommand.mockResolvedValueOnce({
|
||||
type: 'cityOfficeRequest',
|
||||
ok: false,
|
||||
generalId: 22,
|
||||
reason: '이미 처리된 요청',
|
||||
});
|
||||
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user