Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { AdminModule } from './admin/admin.module'
import { ClickHouseModule } from './clickhouse/clickhouse.module'
import { BoxTelemetryModule } from './box-telemetry/box-telemetry.module'
import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module'
import { UsageModule } from './usage/usage.module'

@Module({
imports: [
Expand Down Expand Up @@ -199,6 +200,7 @@ import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module'
ClickHouseModule,
BoxTelemetryModule,
BoxliteRestModule,
UsageModule,
OpenFeatureModule.forRoot({
contextFactory: (request: ExecutionContext) => {
const req = request.switchToHttp().getRequest()
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/migrations/pre-deploy/1782700000000-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm'

export class Migration1782700000000 implements MigrationInterface {
name = 'Migration1782700000000'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "box_usage_period" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"boxId" character varying NOT NULL,
"organizationId" character varying NOT NULL,
"startAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"endAt" TIMESTAMP WITH TIME ZONE,
"cpu" double precision NOT NULL,
"gpu" double precision NOT NULL,
"mem" double precision NOT NULL,
"disk" double precision NOT NULL,
"region" character varying NOT NULL,
"boxClass" character varying NOT NULL DEFAULT 'small',
"regionType" character varying NOT NULL DEFAULT 'shared',
CONSTRAINT "box_usage_period_id_pk" PRIMARY KEY ("id")
)`,
)
await queryRunner.query(`CREATE INDEX "box_usage_period_box_end_idx" ON "box_usage_period" ("boxId", "endAt")`)
await queryRunner.query(
`CREATE UNIQUE INDEX "box_usage_period_one_open_per_box_idx" ON "box_usage_period" ("boxId") WHERE "endAt" IS NULL`,
)

await queryRunner.query(
`CREATE TABLE "box_usage_period_archive" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"boxId" character varying NOT NULL,
"organizationId" character varying NOT NULL,
"startAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"endAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"cpu" double precision NOT NULL,
"gpu" double precision NOT NULL,
"mem" double precision NOT NULL,
"disk" double precision NOT NULL,
"region" character varying NOT NULL,
"boxClass" character varying NOT NULL DEFAULT 'small',
"regionType" character varying NOT NULL DEFAULT 'shared',
CONSTRAINT "box_usage_period_archive_id_pk" PRIMARY KEY ("id")
)`,
)
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "box_usage_period_archive"`)
await queryRunner.query(`DROP TABLE "box_usage_period"`)
}
}
66 changes: 66 additions & 0 deletions apps/api/src/usage/entities/box-usage-period-archive.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright BoxLite AI, 2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import { BoxClass } from '../../box/enums/box-class.enum'
import { RegionType } from '../../region/enums/region-type.enum'
import { BoxUsagePeriod } from './box-usage-period.entity'

// Duplicate of BoxUsagePeriod. It only contains closed periods and keeps the active table lightweight.
@Entity('box_usage_period_archive')
export class BoxUsagePeriodArchive {
@PrimaryGeneratedColumn('uuid')
id: string

@Column()
boxId: string

@Column()
// Redundant property to optimize billing queries.
organizationId: string

@Column({ type: 'timestamp with time zone' })
startAt: Date

@Column({ type: 'timestamp with time zone' })
endAt: Date

@Column({ type: 'float' })
cpu: number

@Column({ type: 'float' })
gpu: number

@Column({ type: 'float' })
mem: number

@Column({ type: 'float' })
disk: number

@Column()
region: string

@Column({ type: 'character varying', default: BoxClass.SMALL })
boxClass: BoxClass = BoxClass.SMALL

@Column({ type: 'character varying', default: RegionType.SHARED })
regionType: string = RegionType.SHARED

public static fromBoxUsagePeriod(usagePeriod: BoxUsagePeriod) {
const usagePeriodEntity = new BoxUsagePeriodArchive()
usagePeriodEntity.boxId = usagePeriod.boxId
usagePeriodEntity.organizationId = usagePeriod.organizationId
usagePeriodEntity.startAt = usagePeriod.startAt
usagePeriodEntity.endAt = usagePeriod.endAt as Date

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Unsafe as Date cast on endAt masks null values.

usagePeriod.endAt is Date | null, but the archive column is NOT NULL. The as Date cast silences the compiler; if an open period is accidentally passed, it will fail at the DB level with a constraint violation rather than being caught at compile time. Consider a runtime guard or explicit error.

🛡️ Proposed fix
  public static fromUsagePeriod(usagePeriod: UsagePeriod) {
+   if (!usagePeriod.endAt) {
+     throw new Error('Cannot archive a usage period with null endAt')
+   }
    const usagePeriodEntity = new UsagePeriodArchive()
    usagePeriodEntity.boxId = usagePeriod.boxId
    usagePeriodEntity.organizationId = usagePeriod.organizationId
    usagePeriodEntity.startAt = usagePeriod.startAt
-   usagePeriodEntity.endAt = usagePeriod.endAt as Date
+   usagePeriodEntity.endAt = usagePeriod.endAt
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
usagePeriodEntity.endAt = usagePeriod.endAt as Date
if (!usagePeriod.endAt) {
throw new Error('Cannot archive a usage period with null endAt')
}
usagePeriodEntity.endAt = usagePeriod.endAt
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/entities/usage-period-archive.entity.ts` at line 56,
Replace the unsafe cast in the usage-period archive mapping with an explicit
null check for usagePeriod.endAt, throwing a clear error when it is null before
assigning usagePeriodEntity.endAt. Keep the archive entity’s non-null Date
contract intact and reference the surrounding archive creation logic for the
guard placement.

usagePeriodEntity.cpu = usagePeriod.cpu
usagePeriodEntity.gpu = usagePeriod.gpu
usagePeriodEntity.mem = usagePeriod.mem
usagePeriodEntity.disk = usagePeriod.disk
usagePeriodEntity.region = usagePeriod.region
usagePeriodEntity.boxClass = usagePeriod.boxClass
usagePeriodEntity.regionType = usagePeriod.regionType
return usagePeriodEntity
}
}
66 changes: 66 additions & 0 deletions apps/api/src/usage/entities/box-usage-period.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright BoxLite AI, 2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
import { BoxClass } from '../../box/enums/box-class.enum'
import { RegionType } from '../../region/enums/region-type.enum'

@Entity('box_usage_period')
@Index('box_usage_period_box_end_idx', ['boxId', 'endAt'])
@Index('box_usage_period_one_open_per_box_idx', ['boxId'], { unique: true, where: '"endAt" IS NULL' })
export class BoxUsagePeriod {
@PrimaryGeneratedColumn('uuid')
id: string

@Column()
boxId: string

@Column()
// Redundant property to optimize billing queries.
organizationId: string

@Column({ type: 'timestamp with time zone' })
startAt: Date

@Column({ type: 'timestamp with time zone', nullable: true })
endAt: Date | null

@Column({ type: 'float' })
cpu: number

@Column({ type: 'float' })
gpu: number

@Column({ type: 'float' })
mem: number

@Column({ type: 'float' })
disk: number

@Column()
region: string

@Column({ type: 'character varying', default: BoxClass.SMALL })
boxClass: BoxClass = BoxClass.SMALL

@Column({ type: 'character varying', default: RegionType.SHARED })
regionType: string = RegionType.SHARED

public static fromBoxUsagePeriod(usagePeriod: BoxUsagePeriod) {
const usagePeriodEntity = new BoxUsagePeriod()
usagePeriodEntity.boxId = usagePeriod.boxId
usagePeriodEntity.organizationId = usagePeriod.organizationId
usagePeriodEntity.startAt = usagePeriod.startAt
usagePeriodEntity.endAt = usagePeriod.endAt
usagePeriodEntity.cpu = usagePeriod.cpu
usagePeriodEntity.gpu = usagePeriod.gpu
usagePeriodEntity.mem = usagePeriod.mem
usagePeriodEntity.disk = usagePeriod.disk
usagePeriodEntity.region = usagePeriod.region
usagePeriodEntity.boxClass = usagePeriod.boxClass
usagePeriodEntity.regionType = usagePeriod.regionType
return usagePeriodEntity
}
}
Loading
Loading