mirror of
https://github.com/wassname/talk.git
synced 2026-07-31 12:50:48 +08:00
[next] Error and Logging Improvements (#2152)
* feat: added locale support for Tenant * feat: added secret scrubbing to logs * chore: cleanup logger * chore: logger improvements * feat: re-introduce scoped pretty logger * feat: added initial error support * refactor: replace trace-error.TraceError with talk.InternalError * fix: fixed error logging * refactor: replaced Error with VError * fix: repaired issue with error management on api * fix: patched bug with not found handler * feat: added translations * feat: added location path to invalid entries * refactor: refactored error handling on graph * fix: moved indexing operations to master node * refactor: added throw for when the message isn't found in testing * fix: removed duplicate log * fix: fixed naming on environment variable
This commit is contained in:
@@ -1,29 +1,43 @@
|
||||
import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
import { Config } from "talk-server/config";
|
||||
import logger from "talk-server/logger";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface CommonContextOptions {
|
||||
user?: User;
|
||||
req?: Request;
|
||||
lang?: LanguageCode;
|
||||
config: Config;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default class CommonContext {
|
||||
public readonly user?: User;
|
||||
public readonly req?: Request;
|
||||
public readonly config: Config;
|
||||
public readonly i18n: I18n;
|
||||
public readonly lang: LanguageCode;
|
||||
|
||||
public readonly logger = logger.child({
|
||||
context: "graph",
|
||||
contextID: uuid.v4(),
|
||||
contextID: uuid.v1(),
|
||||
});
|
||||
|
||||
constructor({ user, req, config }: CommonContextOptions) {
|
||||
constructor({
|
||||
user,
|
||||
req,
|
||||
config,
|
||||
i18n,
|
||||
lang = i18n.getDefaultLang(),
|
||||
}: CommonContextOptions) {
|
||||
this.user = user;
|
||||
this.req = req;
|
||||
this.config = config;
|
||||
this.i18n = i18n;
|
||||
this.lang = lang;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DirectiveResolverFn } from "graphql-tools";
|
||||
import { memoize } from "lodash";
|
||||
|
||||
import { GraphQLResolveInfo, ResponsePath } from "graphql";
|
||||
import { UserForbiddenError } from "talk-server/errors";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
import {
|
||||
GQLUSER_AUTH_CONDITIONS,
|
||||
@@ -31,6 +33,38 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
|
||||
return conditions.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* calculateLocationKey will reduce the resolve information to determine the
|
||||
* path to where the key that is being accessed.
|
||||
*
|
||||
* @param info the info from the graph request
|
||||
*/
|
||||
function calculateLocationKey(info: Pick<GraphQLResolveInfo, "path">): string {
|
||||
// Guard against invalid input.
|
||||
if (!info || !info.path || !info.path.key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Grab the first part of the path.
|
||||
const parts: string[] = [info.path.key.toString()];
|
||||
|
||||
// Grab the parent previous part of the path.
|
||||
let prev: ResponsePath | undefined = info.path.prev;
|
||||
|
||||
// While there is still a previous part of the path, keep looping to find the
|
||||
// all the parts.
|
||||
while (prev && prev.key) {
|
||||
// Push the key into the front of the array.
|
||||
parts.unshift(prev.key.toString());
|
||||
|
||||
// Change the selection to the previous path element.
|
||||
prev = prev.prev;
|
||||
}
|
||||
|
||||
// Join it together with a dotted path.
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
const calculateAuthConditionsMemoized = memoize(calculateAuthConditions);
|
||||
|
||||
const auth: DirectiveResolverFn<
|
||||
@@ -40,7 +74,8 @@ const auth: DirectiveResolverFn<
|
||||
next,
|
||||
src,
|
||||
{ roles, userIDField, permit }: AuthDirectiveArgs,
|
||||
{ user }
|
||||
{ user },
|
||||
info
|
||||
) => {
|
||||
// If there is a user on the request.
|
||||
if (user) {
|
||||
@@ -48,8 +83,11 @@ const auth: DirectiveResolverFn<
|
||||
// User, if they do error.
|
||||
const conditions = calculateAuthConditionsMemoized(user);
|
||||
if (!permit && conditions.length > 0) {
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized 1");
|
||||
throw new UserForbiddenError(
|
||||
"authentication conditions not met",
|
||||
calculateLocationKey(info),
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
// If the permit was specified, and some of the conditions for the user
|
||||
@@ -58,8 +96,11 @@ const auth: DirectiveResolverFn<
|
||||
permit &&
|
||||
conditions.some(condition => permit.indexOf(condition) === -1)
|
||||
) {
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized 2");
|
||||
throw new UserForbiddenError(
|
||||
"authentication conditions not met",
|
||||
calculateLocationKey(info),
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
// If the role and user owner checks are disabled, then allow them based on
|
||||
@@ -80,8 +121,11 @@ const auth: DirectiveResolverFn<
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized");
|
||||
throw new UserForbiddenError(
|
||||
"user does not have permission to access the resource",
|
||||
calculateLocationKey(info),
|
||||
user ? user.id : null
|
||||
);
|
||||
};
|
||||
|
||||
export default auth;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { TalkError } from "talk-server/errors";
|
||||
|
||||
/**
|
||||
* mapFieldsetToErrorCodes will wait for any errors to occur with the request,
|
||||
* and then associate the appropriate field that caused the error to the error
|
||||
* itself so it can link context in the UI.
|
||||
*
|
||||
* @param promise the promise to await on for any errors to occur
|
||||
* @param errorMap the map of error codes to associate with a given fieldSet
|
||||
*/
|
||||
export async function mapFieldsetToErrorCodes<T>(
|
||||
promise: Promise<T>,
|
||||
errorMap: Record<string, ERROR_CODES[]>
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
// If the error is a TalkError...
|
||||
if (err instanceof TalkError) {
|
||||
// Then loop over all the fieldSpecs...
|
||||
for (const param in errorMap) {
|
||||
if (!errorMap.hasOwnProperty(param)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (errorMap[param].some(code => err.code === code)) {
|
||||
err.param = param;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { GraphQLExtension, GraphQLResponse } from "graphql-extensions";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { InternalError, TalkError } from "talk-server/errors";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
|
||||
function hoistTalkErrorExtensions(ctx: CommonContext, err: GraphQLError): void {
|
||||
if (!err.originalError) {
|
||||
// Only errors that have an originalError need to be hoisted.
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab or wrap the originalError so that it's a TalkError.
|
||||
const originalError: TalkError =
|
||||
err.originalError instanceof TalkError
|
||||
? err.originalError
|
||||
: new InternalError(err.originalError, "wrapped internal error");
|
||||
|
||||
// Get the translation bundle.
|
||||
const bundle = ctx.i18n.getBundle(ctx.lang);
|
||||
|
||||
// Translate the extensions.
|
||||
const extensions = originalError.serializeExtensions(bundle);
|
||||
|
||||
// Hoist the message from the original error into the message of the base
|
||||
// error.
|
||||
err.message = extensions.message;
|
||||
|
||||
// Re-hoist the extensions.
|
||||
merge(err.extensions, extensions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichAndLogError will enrich and then log out the error.
|
||||
*
|
||||
* @param ctx the GraphQL context for the request
|
||||
* @param err the error that occurred
|
||||
*/
|
||||
export function enrichError(
|
||||
ctx: CommonContext,
|
||||
err: GraphQLError
|
||||
): GraphQLError {
|
||||
if (err.extensions) {
|
||||
// Delete the exception field from the error extension, we never need to
|
||||
// provide that data.
|
||||
if (err.extensions.exception) {
|
||||
delete err.extensions.exception;
|
||||
}
|
||||
|
||||
if (err.originalError) {
|
||||
// Hoist the extensions onto the error.
|
||||
hoistTalkErrorExtensions(ctx, err);
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
export class ErrorWrappingExtension implements GraphQLExtension<CommonContext> {
|
||||
public willSendResponse(o: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: CommonContext;
|
||||
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
|
||||
if (o.graphqlResponse.errors) {
|
||||
return {
|
||||
...o,
|
||||
graphqlResponse: {
|
||||
...o.graphqlResponse,
|
||||
errors: o.graphqlResponse.errors.map(err =>
|
||||
enrichError(o.context, err)
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-24
@@ -1,4 +1,3 @@
|
||||
import { formatApolloErrors } from "apollo-server-errors";
|
||||
import {
|
||||
DocumentNode,
|
||||
ExecutionArgs,
|
||||
@@ -14,17 +13,11 @@ import now from "performance-now";
|
||||
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
|
||||
export function logError(ctx: CommonContext, err: GraphQLError) {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
}
|
||||
|
||||
export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
private logError = (ctx: CommonContext) => (err: Error) => {
|
||||
if (err instanceof GraphQLError) {
|
||||
ctx.logger.error({ err: err.originalError }, "graphql error");
|
||||
} else {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
private getOperationMetadata(doc: DocumentNode) {
|
||||
if (doc.kind === "Document") {
|
||||
const operationDefinition = doc.definitions.find(
|
||||
@@ -70,21 +63,14 @@ export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
}
|
||||
}
|
||||
|
||||
public willSendResponse(o: {
|
||||
public willSendResponse(response: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: CommonContext;
|
||||
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
|
||||
if (o.graphqlResponse.errors) {
|
||||
return {
|
||||
...o,
|
||||
graphqlResponse: {
|
||||
...o.graphqlResponse,
|
||||
errors: formatApolloErrors(o.graphqlResponse.errors, {
|
||||
formatter: this.logError(o.context),
|
||||
debug: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}): void {
|
||||
if (response.graphqlResponse.errors) {
|
||||
response.graphqlResponse.errors.forEach(err =>
|
||||
logError(response.context, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { Config } from "talk-server/config";
|
||||
import { LoggerExtension } from "talk-server/graph/common/middleware/extensions/logger";
|
||||
|
||||
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
|
||||
import { LoggerExtension } from "./extensions/LoggerExtension";
|
||||
|
||||
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
|
||||
const NoIntrospection = (context: ValidationContext) => ({
|
||||
@@ -36,7 +38,7 @@ export const graphqlMiddleware = (
|
||||
debug: false,
|
||||
// Include extensions.
|
||||
extensions: [
|
||||
// Log queries and errors.
|
||||
() => new ErrorWrappingExtension(),
|
||||
() => new LoggerExtension(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { Config } from "talk-server/config";
|
||||
import { createRedisClient } from "talk-server/services/redis";
|
||||
|
||||
export function createPubSub(config: Config): RedisPubSub {
|
||||
export async function createPubSub(config: Config): Promise<RedisPubSub> {
|
||||
// Create the Redis clients for the PubSub server.
|
||||
const publisher = createRedisClient(config);
|
||||
const subscriber = createRedisClient(config);
|
||||
const publisher = await createRedisClient(config);
|
||||
const subscriber = await createRedisClient(config);
|
||||
|
||||
// Create the new PubSub manager.
|
||||
return new RedisPubSub({
|
||||
|
||||
@@ -2,19 +2,21 @@ import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "talk-server/config";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface ManagementContextOptions {
|
||||
mongo: Db;
|
||||
config: Config;
|
||||
i18n: I18n;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
export default class ManagementContext extends CommonContext {
|
||||
public readonly mongo: Db;
|
||||
|
||||
constructor({ req, mongo, config }: ManagementContextOptions) {
|
||||
super({ req, config });
|
||||
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
|
||||
super({ req, config, i18n });
|
||||
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,23 @@ import { Config } from "talk-server/config";
|
||||
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import ManagementContext from "./context";
|
||||
|
||||
export default (schema: GraphQLSchema, config: Config, mongo: Db) =>
|
||||
export interface ManagementGraphQLMiddlewareOptions {
|
||||
schema: GraphQLSchema;
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default ({
|
||||
schema,
|
||||
config,
|
||||
mongo,
|
||||
i18n,
|
||||
}: ManagementGraphQLMiddlewareOptions) =>
|
||||
graphqlMiddleware(config, async (req: Request) => ({
|
||||
schema,
|
||||
context: new ManagementContext({ req, mongo, config }),
|
||||
context: new ManagementContext({ req, mongo, config, i18n }),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AugmentedRedis } from "talk-server/services/redis";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import loaders from "./loaders";
|
||||
import mutators from "./mutators";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface TenantContextOptions {
|
||||
signingConfig?: JWTSigningConfig;
|
||||
req?: Request;
|
||||
user?: User;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default class TenantContext extends CommonContext {
|
||||
@@ -46,8 +48,9 @@ export default class TenantContext extends CommonContext {
|
||||
tenantCache,
|
||||
queue,
|
||||
signingConfig,
|
||||
i18n,
|
||||
}: TenantContextOptions) {
|
||||
super({ user, req, config });
|
||||
super({ user, req, config, i18n, lang: tenant.locale });
|
||||
|
||||
this.tenant = tenant;
|
||||
this.tenantCache = tenantCache;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isNull, omitBy } from "lodash";
|
||||
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { GQLUpdateSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { regenerateSSOKey, update } from "talk-server/services/tenant";
|
||||
|
||||
@@ -11,8 +11,8 @@ export const Settings = ({
|
||||
tenantCache,
|
||||
tenant,
|
||||
}: TenantContext) => ({
|
||||
update: (input: GQLSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, omitBy(input.settings, isNull)),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isNull, omitBy } from "lodash";
|
||||
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import {
|
||||
GQLCreateStoryInput,
|
||||
@@ -16,17 +18,33 @@ export const Story = (ctx: TenantContext) => ({
|
||||
create: async (
|
||||
input: GQLCreateStoryInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
omitBy(input.story, isNull)
|
||||
mapFieldsetToErrorCodes(
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
omitBy(input.story, isNull)
|
||||
),
|
||||
{
|
||||
"input.story.url": [
|
||||
ERROR_CODES.STORY_URL_NOT_PERMITTED,
|
||||
ERROR_CODES.DUPLICATE_STORY_URL,
|
||||
],
|
||||
}
|
||||
),
|
||||
update: async (
|
||||
input: GQLUpdateStoryInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
|
||||
mapFieldsetToErrorCodes(
|
||||
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
|
||||
{
|
||||
"input.story.url": [
|
||||
ERROR_CODES.STORY_URL_NOT_PERMITTED,
|
||||
ERROR_CODES.DUPLICATE_STORY_URL,
|
||||
],
|
||||
}
|
||||
),
|
||||
merge: async (
|
||||
input: GQLMergeStoriesInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import * as user from "talk-server/models/user";
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
updateRole,
|
||||
updateUsername,
|
||||
} from "talk-server/services/users";
|
||||
|
||||
import {
|
||||
GQLCreateTokenInput,
|
||||
GQLDeactivateTokenInput,
|
||||
@@ -31,11 +34,32 @@ export const User = (ctx: TenantContext) => ({
|
||||
setUsername: async (
|
||||
input: GQLSetUsernameInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
|
||||
mapFieldsetToErrorCodes(
|
||||
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
|
||||
{
|
||||
"input.username": [
|
||||
ERROR_CODES.USERNAME_ALREADY_SET,
|
||||
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
|
||||
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
|
||||
ERROR_CODES.USERNAME_TOO_SHORT,
|
||||
ERROR_CODES.DUPLICATE_USERNAME,
|
||||
],
|
||||
}
|
||||
),
|
||||
setEmail: async (
|
||||
input: GQLSetEmailInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
|
||||
mapFieldsetToErrorCodes(
|
||||
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
|
||||
{
|
||||
"input.email": [
|
||||
ERROR_CODES.EMAIL_ALREADY_SET,
|
||||
ERROR_CODES.DUPLICATE_EMAIL,
|
||||
ERROR_CODES.EMAIL_INVALID_FORMAT,
|
||||
ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH,
|
||||
],
|
||||
}
|
||||
),
|
||||
setPassword: async (
|
||||
input: GQLSetPasswordInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
import { LOCALES } from "./LOCALES";
|
||||
|
||||
it("does not contain duplicate entries", () => {
|
||||
const seen: Partial<Record<LanguageCode, true>> = {};
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value in seen).toBeFalsy();
|
||||
seen[value] = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("contains the correct mappings to the BCP 47 format", () => {
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value).toEqual(key.replace(/_/, "-"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
|
||||
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
|
||||
en_US: "en-US",
|
||||
es: "es",
|
||||
de: "de",
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
updateSettings: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.update(input.settings),
|
||||
settings: await ctx.mutators.Settings.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createCommentReaction: async (source, { input }, ctx) => ({
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
|
||||
import {
|
||||
addResolveFunctionsToSchema,
|
||||
attachDirectiveResolvers,
|
||||
IEnumResolver,
|
||||
IResolvers,
|
||||
} from "graphql-tools";
|
||||
|
||||
import { loadSchema } from "talk-common/graphql";
|
||||
import auth from "talk-server/graph/common/directives/auth";
|
||||
import resolvers from "talk-server/graph/tenant/resolvers";
|
||||
import { LOCALES } from "talk-server/graph/tenant/resolvers/LOCALES";
|
||||
|
||||
export default function getTenantSchema() {
|
||||
const schema = loadSchema("tenant", resolvers as IResolvers);
|
||||
@@ -10,5 +16,15 @@ export default function getTenantSchema() {
|
||||
// Attach the directive resolvers.
|
||||
attachDirectiveResolvers(schema, { auth });
|
||||
|
||||
// Attach the GraphQL enum fields.
|
||||
addResolveFunctionsToSchema({
|
||||
schema,
|
||||
resolvers: {
|
||||
// For some reason, the resolver doesn't quite work without coercing the
|
||||
// type.
|
||||
LOCALES: LOCALES as IEnumResolver,
|
||||
},
|
||||
});
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -825,6 +825,16 @@ type ReactionConfiguration {
|
||||
## Settings
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
LOCALES list all the supported locales in a modified BCP 47 format, where the
|
||||
hyphen is replaced by an underscore.
|
||||
"""
|
||||
enum LOCALES {
|
||||
en_US
|
||||
es
|
||||
de
|
||||
}
|
||||
|
||||
"""
|
||||
Settings stores the global settings for a given Tenant.
|
||||
"""
|
||||
@@ -840,10 +850,15 @@ type Settings {
|
||||
domain: String! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
domains will return a given list of permitted domains.
|
||||
"""
|
||||
domains: [String!] @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
locale is the specified locale for this Tenant.
|
||||
"""
|
||||
locale: LOCALES!
|
||||
|
||||
"""
|
||||
moderation is the moderation mode for all Stories on the site.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user