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

feat(api): implement bulk inventory creation admin API endpoint #1159

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
15 changes: 15 additions & 0 deletions app/migrations/20250227103030-data-source-priority.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("DataSourceI18n", "priority", {
type: Sequelize.DOUBLE,
nullable: true,
});
},

async down(queryInterface) {
await queryInterface.removeColumn("DataSourceI18n", "priority");
},
};
18 changes: 18 additions & 0 deletions app/src/app/api/v0/admin/bulk/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import AdminService from "@/backend/AdminService";
import { apiHandler } from "@/util/api";
import { NextResponse } from "next/server";
import { z } from "zod";

const createBulkInventoriesRequest = z.object({
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be moved to our validations.

cityLocodes: z.array(z.string()), // List of city locodes
Copy link

Choose a reason for hiding this comment

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

Insufficient Locode Format Validation category Functionality

Tell me more
What is the issue?

The cityLocodes validation accepts any string without validating if it's a proper locode format.

Why this matters

Invalid locode formats could cause downstream processing issues or data inconsistencies in the inventory creation process.

Suggested change ∙ Feature Preview

Add a regex pattern to validate locode format:

const LOCODE_PATTERN = /^[A-Z]{5}$/;
const createBulkInventoriesRequest = z.object({
  cityLocodes: z.array(z.string().regex(LOCODE_PATTERN, "Invalid locode format")).min(1),
  // ... rest of the schema
});

Report a problem with this comment

💬 Chat with Korbit by mentioning @korbit-ai.

emails: z.array(z.string().email()), // Comma separated list of emails to invite to the all of the created inventories
years: z.array(z.number().int().positive()), // List of years to create inventories for (can be comma separated input, multiple select dropdown etc., so multiple years can be chosen)
scope: z.enum(["gpc_basic", "gpc_basic_plus"]), // Scope selection (gpc_basic or gpc_basic_plus)
gwp: z.enum(["AR5", "AR6"]), // GWP selection (AR5 or AR6)
});

export const POST = apiHandler(async (req, { session }) => {
const props = createBulkInventoriesRequest.parse(await req.json());
const result = await AdminService.createBulkInventories(props, session);
return NextResponse.json(result);
});
4 changes: 2 additions & 2 deletions app/src/app/api/v0/city/[city]/user/[user]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const updateUserRequest = z.object({
role: z.nativeEnum(Roles),
});

export const PATCH = apiHandler(async (_req, { params, session }) => {
const body = updateUserRequest.parse(await _req.json());
export const PATCH = apiHandler(async (req, { params, session }) => {
const body = updateUserRequest.parse(await req.json());
let user = await db.models.User.findOne({ where: { userId: params.user } });

if (!user) {
Expand Down
189 changes: 19 additions & 170 deletions app/src/app/api/v0/datasource/[inventoryId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import DataSourceService from "@/backend/DataSourceService";
import DataSourceService, {
downscaledByCountryPopulation,
downscaledByRegionPopulation,
} from "@/backend/DataSourceService";
import { db } from "@/models";
import { City } from "@/models/City";
import { DataSourceI18n as DataSource } from "@/models/DataSourceI18n";
Expand All @@ -9,84 +12,7 @@ import { SubSector } from "@/models/SubSector";
import { apiHandler } from "@/util/api";
import createHttpError from "http-errors";
import { NextRequest, NextResponse } from "next/server";
import { Op } from "sequelize";
import { z } from "zod";
import { logger } from "@/services/logger";
import { Publisher } from "@/models/Publisher";
import { findClosestYear, PopulationEntry } from "@/util/helpers";
import { PopulationAttributes } from "@/models/Population";
import { Inventory } from "@/models/Inventory";
import { maxPopulationYearDifference } from "@/util/constants";

const downscaledByCountryPopulation = "global_api_downscaled_by_population";
const downscaledByRegionPopulation =
"global_api_downscaled_by_region_population";
const populationScalingRetrievalMethods = [
downscaledByCountryPopulation,
downscaledByRegionPopulation,
];

async function findPopulationScaleFactors(
inventory: Inventory,
sources: DataSource[],
) {
let countryPopulationScaleFactor = 1;
let regionPopulationScaleFactor = 1;
let populationIssue: string | null = null;
if (
sources.some((source) =>
populationScalingRetrievalMethods.includes(source.retrievalMethod ?? ""),
)
) {
const populations = await db.models.Population.findAll({
where: {
cityId: inventory.cityId,
year: {
[Op.between]: [
inventory.year! - maxPopulationYearDifference,
inventory.year! + maxPopulationYearDifference,
],
},
},
order: [["year", "DESC"]], // favor more recent population entries
});
const cityPopulations = populations.filter((pop) => !!pop.population);
const cityPopulation = findClosestYear(
cityPopulations as PopulationEntry[],
inventory.year!,
);
const countryPopulations = populations.filter(
(pop) => !!pop.countryPopulation,
);
const countryPopulation = findClosestYear(
countryPopulations as PopulationEntry[],
inventory.year!,
) as PopulationAttributes;
const regionPopulations = populations.filter(
(pop) => !!pop.regionPopulation,
);
const regionPopulation = findClosestYear(
regionPopulations as PopulationEntry[],
inventory.year!,
) as PopulationAttributes;
// TODO allow country downscaling to work if there is no region population?
if (!cityPopulation || !countryPopulation || !regionPopulation) {
// City is missing population/ region population/ country population for a year close to the inventory year
populationIssue = "missing-population"; // translation key
} else {
countryPopulationScaleFactor =
cityPopulation.population / countryPopulation.countryPopulation!;
regionPopulationScaleFactor =
cityPopulation.population / regionPopulation.regionPopulation!;
}
}

return {
countryPopulationScaleFactor,
regionPopulationScaleFactor,
populationIssue,
};
}

export const GET = apiHandler(async (_req: NextRequest, { params }) => {
const inventory = await db.models.Inventory.findOne({
Expand All @@ -97,48 +23,7 @@ export const GET = apiHandler(async (_req: NextRequest, { params }) => {
throw new createHttpError.NotFound("Inventory not found");
}

const include = [
{
model: DataSource,
as: "dataSources",
include: [
{ model: Scope, as: "scopes" },
{ model: Publisher, as: "publisher" },
{
model: InventoryValue,
as: "inventoryValues",
required: false,
where: { inventoryId: params.inventoryId },
},
{ model: SubSector, as: "subSector" },
{
model: SubCategory,
as: "subCategory",
include: [
{ model: SubSector, as: "subsector" },
{ model: Scope, as: "scope" },
],
},
],
},
];

const sectors = await db.models.Sector.findAll({ include });
const subSectors = await db.models.SubSector.findAll({ include });
const subCategories = await db.models.SubCategory.findAll({ include });

const sectorSources = sectors.flatMap((sector) => sector.dataSources);
const subSectorSources = subSectors.flatMap(
(subSector) => subSector.dataSources,
);
const subCategorySources = subCategories.flatMap(
(subCategory) => subCategory.dataSources,
);

const sources = sectorSources
.concat(subSectorSources)
.concat(subCategorySources);

const sources = await DataSourceService.findAllSources(params.inventoryId);
const { applicableSources, removedSources } = DataSourceService.filterSources(
inventory,
sources,
Expand All @@ -149,7 +34,10 @@ export const GET = apiHandler(async (_req: NextRequest, { params }) => {
countryPopulationScaleFactor,
regionPopulationScaleFactor,
populationIssue,
} = await findPopulationScaleFactors(inventory, applicableSources);
} = await DataSourceService.findPopulationScaleFactors(
inventory,
applicableSources,
);

// TODO add query parameter to make this optional?
const sourceData = await Promise.all(
Expand Down Expand Up @@ -234,59 +122,20 @@ export const POST = apiHandler(async (req: NextRequest, { params }) => {
// TODO check if the user has made manual edits that would be overwritten
// TODO create new versioning record

const {
countryPopulationScaleFactor,
regionPopulationScaleFactor,
populationIssue,
} = await findPopulationScaleFactors(inventory, applicableSources);
const populationScaleFactors =
await DataSourceService.findPopulationScaleFactors(
inventory,
applicableSources,
);

// download source data and apply in database
const sourceResults = await Promise.all(
applicableSources.map(async (source) => {
const result: { id: string; success: boolean; issue?: string } = {
id: source.datasourceId,
success: true,
issue: undefined,
};

if (source.retrievalMethod === "global_api") {
const sourceStatus = await DataSourceService.applyGlobalAPISource(
source,
inventory,
);
if (typeof sourceStatus === "string") {
result.issue = sourceStatus;
result.success = false;
}
} else if (
populationScalingRetrievalMethods.includes(source.retrievalMethod ?? "")
) {
if (populationIssue) {
result.issue = populationIssue;
result.success = false;
return result;
}
let scaleFactor = 1.0;
if (source.retrievalMethod === downscaledByCountryPopulation) {
scaleFactor = countryPopulationScaleFactor;
} else if (source.retrievalMethod === downscaledByRegionPopulation) {
scaleFactor = regionPopulationScaleFactor;
}
const sourceStatus = await DataSourceService.applyGlobalAPISource(
source,
inventory,
scaleFactor,
);
if (typeof sourceStatus === "string") {
result.issue = sourceStatus;
result.success = false;
}
} else {
result.issue = `Unsupported retrieval method ${source.retrievalMethod} for data source ${source.datasourceId}`;
logger.error(result.issue);
result.success = false;
}

const result = await DataSourceService.applySource(
source,
inventory,
populationScaleFactors,
);
return result;
}),
);
Expand Down
Loading
Loading