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

wip: feat: collection service & controller #3971

Draft
wants to merge 46 commits into
base: main
Choose a base branch
from

Conversation

zz-hh-aa
Copy link
Collaborator

@zz-hh-aa zz-hh-aa commented Nov 18, 2024

Everything seems to work now--all of my tests pass and I have also successfully used Insomnia to check and update the PlanX DB (locally) and create collections in Metabase (staging).

I think the next steps would be to break this up into two PRs with cleaner commit histories. Instinctively I would put everything that interacts with the PlanX DB into one (getTeamAndMetabaseId and updateMetabaseId) plus tests, and everything that only interacts with Metabase into another (createCollection). OR I could put the controller in one along with the routes update, and leave everything else in a bigger one? Feedback and thoughts very welcome!

What does this PR do?

PlanX DB

  • Creates a migration adding a new column metabase_id to teams
  • Updates api permissions to be able to update metabase_id and select others

Controller and routes

  • Creates a new route in PlanX API
  • Creates a new controller that runs checkCollections service

Service

  • checkCollections is the main service, which runs other relevant functions
    • getTeamAndMetabaseId fetches id and metabase_id from teams
    • if there is no metabase-id, then createCollection runs, creating new collection and returning Metabase collection ID
    • updateMetabaseId runs updating teams.metabase_id

There are also tests!

Questions

  • Should getTeamAndMetabaseId be broken up into getTeam and getMetabaseId?
  • Did I update the api permissions correctly 😅

Copy link

github-actions bot commented Nov 18, 2024

Pizza

Deployed b2702f1 to https://3971.planx.pizza.

Useful links:

@zz-hh-aa zz-hh-aa marked this pull request as ready for review November 18, 2024 16:12
@zz-hh-aa zz-hh-aa changed the base branch from main to oz/metabase-client-setup November 18, 2024 16:22
@zz-hh-aa zz-hh-aa requested a review from a team November 18, 2024 16:23
Copy link
Contributor

@DafyddLlyr DafyddLlyr left a comment

Choose a reason for hiding this comment

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

A few minor comment and questions on the code.

I think a stronger pattern here would be to store Metabase collection IDs to the PlanX database. This will mean we don't have to query all Metabase collections and find the team each time we make a request. We can manually populate these values for existing teams.

When we add a new team, we want to automatically create a collection for them. This action should return an ID which we can persist in our database. When a new flow is created within this team, we'll then already have the collection ID to hand to ensure the dashboard is created in the correct collection.

I think this would be both simpler and a bit more robust (less room for failures) 👍

Comment on lines 13 to 16
const name = _req.query.name as string;

if (!name) {
return res.status(400).json({ error: "Name parameter is required" });
Copy link
Contributor

Choose a reason for hiding this comment

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

We should be using the type for this request handler defined in types.ts - this is already handling this validation and parsing for us.

Comment on lines 69 to 62
/** Checks if a collection exists with name matching `teamName` exists.
* Returns the matching collection ID if exists, otherwise false. */
export async function checkCollections(teamName: string): Promise<any> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not yet quite picking up when and where this would be used - I guess we need the collection ID (i.e. teamId) in order to make a new dashboard?

If this is the case, we likely won't need a controller and exposed endpoint here - we can just call this in the service when and where we need it.

Comment on lines 90 to 89
console.error("Error: ", error);
if (error instanceof MetabaseError) {
console.error("MetabaseError:", {
message: error.message,
statusCode: error.statusCode,
response: error.response,
});
} else if (error instanceof Error) {
console.error("Unexpected error:", {
message: error.message,
stack: error.stack,
});
} else {
console.error("Unknown error:", error);
}
throw error;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

We already have error handling set up in the API via Express and it's errorHandler() - this should mean we can just throw here with a helpful message and allow it to be caught upstream.

I think we'll need to add a check for MetabaseError there, but that should be it and it will allow us to simplify a lot of this repeated logic 👍

Docs: https://expressjs.com/en/guide/error-handling.html

}),
});

export type CheckCollectionsRequest = ValidatedRequestHandler<
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
export type CheckCollectionsRequest = ValidatedRequestHandler<
export type CheckCollections = ValidatedRequestHandler<

nit: These aren't requests, but request handlers - ValidatedRequestHandler<Req, Res>

Comment on lines 20 to 15
export interface CheckCollectionResponse {
collectionId: number | false;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a bit of an unexpected response - I'd expect maybe number | null or number | undefined;

}

export interface NewCollectionParams {
/** The name of the new collection */
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Redundant

body: z.object({
name: z.string(),
description: z.string().optional(),
parent_id: z.number().optional(),
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's safe to assume all new collections will be grouped here by default.

/** Metbase collection ID for the the "Council" collection **/
const COUNCIL_COLLECTION_ID = 58;

...
parent_id: z.number().default(COUNCIL_COLLECTION_ID)

@zz-hh-aa zz-hh-aa force-pushed the oz/metabase-client-setup branch from 7323e37 to b3947be Compare November 26, 2024 09:54
Base automatically changed from oz/metabase-client-setup to main December 2, 2024 14:08
@zz-hh-aa zz-hh-aa force-pushed the oz/collection-service branch from f873db3 to 1a4ca85 Compare December 2, 2024 17:09
@zz-hh-aa
Copy link
Collaborator Author

zz-hh-aa commented Dec 3, 2024

I think a stronger pattern here would be to store Metabase collection IDs to the PlanX database. This will mean we don't have to query all Metabase collections and find the team each time we make a request. We can manually populate these values for existing teams.

This makes a lot of sense to me! I haven't tried to address this in this PR because a.) I don't know how to deal with the PlanX database and b.) I thought this would get a bit too messy. Let me know if you think this is the right place for it!

Copy link
Contributor

Choose a reason for hiding this comment

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

For functions like this, I'd suggest importing from lodash (which we already use in the API) instead of rolling our own. It means we're using well tested and efficient utility functions, and there's not much overhead in terms of package size.

Comment on lines 33 to 37
const requestBody = toSnakeCase({
name,
description,
parent_id,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Rather than casting to snake_case, the API should accept snake_case, and then we can cast or transform as necessary for the Metabase request.

Incoming request (camelCase) → Validate → Transform to snake_case → Make request to Metabase

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ended up using transform instead of importing the Lodash method.

filterValue: string;
}

export function validateInput(input: unknown): input is Input {
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it's currently unused - is that right?

In terms of validation, it would be best to follow the existing pattern in the API.

→ Incoming request of type unknown
→ Passes through existing validate() middleware in route →
→ Using Zod, is checked against a provided schema →
→ Validated payload is automatically passed along as res.locals.parsedReq
→ Controller and Service have both type safety and a validated payload

Comment on lines 40 to 42
Object.keys(requestBody).forEach(
(key) => requestBody[key] === undefined && delete requestBody[key],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Passing through the validate() middleware should handle this step already - no additional keys would be present.

Comment on lines 7 to 15
export async function authentication(): Promise<boolean> {
try {
const response = await client.get("/user/current");
return response.status === 200;
} catch (error) {
console.error("Error testing Metabase connection:", error);
return false;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This also looks unused currently.

I think this should be a middleware added to the route - before proceeding to make additional requests to Metabase, we can first check the API key is valid.

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 had a quick chat with Jo, who pointed out that it might not be necessary as the authentication function was basically checking that we could get before actually get-ing (which happens in service.ts, in checkCollections():

const response = await client.get(`/api/collection/`);

Between validateConfig() in client.ts and running the get above, does that mean authentication is redundant? Or do we still need another check for the valid API key? I believe the first get would just throw a MetabaseError if it was invalid, is that sufficient?

@zz-hh-aa zz-hh-aa marked this pull request as draft December 10, 2024 14:36
@zz-hh-aa zz-hh-aa changed the title feat: collection service & controller wip: feat: collection service & controller Dec 10, 2024
Copy link

github-actions bot commented Dec 10, 2024

🤖 Hasura Change Summary compared a subset of table metadata including permissions:

Updated Tables (1)

  • public.teams permissions:

    insert select update delete
    api /
    2 added column permissions
    insert select update
    api ➕ metabase_id ➕ metabase_id

@zz-hh-aa zz-hh-aa force-pushed the oz/collection-service branch from e00a6c8 to c384fee Compare December 10, 2024 16:33
* @param id
* @returns
*/
// TODO: is this more suited to be part of the collection.test.ts?
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably better in its own file getCollection.ts or something like that :)

* @returns
*/
// TODO: is this more suited to be part of the collection.test.ts?
export async function getCollection(id: number): Promise<any> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably would be nice to type the response rather than have <any>

*/
export async function checkCollections(
params: NewCollectionParams,
): Promise<any> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as below - would be nice to type the response rather than have <any>

}[];
}

export const getTeamAndMetabaseId = async (name: string) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is fine as one gql request, maybe I'd change the function name to be getTeamIdAndMetabaseId just to be completely explicit

Comment on lines 19 to 20
const teamAndMetabaseId = await getTeamAndMetabaseId(params.name);
const { metabaseId, id } = teamAndMetabaseId;
Copy link
Contributor

Choose a reason for hiding this comment

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

You can do this on one line like this:

    const { metabaseId, id } = await getTeamAndMetabaseId(params.name);

And if you want to recast id to teamId so it's more obvious what it is, you can do:

    const { metabaseId, id: teamId } = await getTeamAndMetabaseId(params.name);

Then make sure you use teamId in the rest of the codeblock.

* @params `name` is required, but `description` and `parent_id` are optional.
* @returns `response.data`, so use dot notation to access `id` or `parent_id`.
*/
export async function checkCollections(
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe I'd call this createCollectionIfDoesNotExist - "checking" is a bit of a vague term

Copy link
Contributor

Choose a reason for hiding this comment

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

Then maybe the controller could just be called CollectionsController or even MetabaseCollectionsController

@jamdelion
Copy link
Contributor

Added a few comments to try and answer some questions! :) I think your intuition about splitting on metabase/planX db lines is correct.

I haven't tested this locally (think I'm missing the metabase env vars) but sounds like it was working from our chat yesterday 🙌 Maybe include some brief testing instructions when you have the PRs split out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants