Skip to content

Commit

Permalink
Merge pull request #1090 from digitalfabrik/1080-add-start-date
Browse files Browse the repository at this point in the history
1080: Add start day
  • Loading branch information
f1sh1918 authored Aug 23, 2023
2 parents 4af8f81 + 2a7061a commit 59dbdf3
Show file tree
Hide file tree
Showing 27 changed files with 375 additions and 49 deletions.
10 changes: 5 additions & 5 deletions administration/resources/cards/sample-bulk-import-nuernberg.csv
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Name,Ablaufdatum,Geburtsdatum,Passnummer,Adresszeile 1,Adresszeile 2,PLZ,Ort,Pass-ID
Thea Test,03.3.2026,05.04.1992,12345678,Wertachstraße 29,,86153,Augsburg,35252
Tilo Torvitz,03.3.2026,13.03.1988,76543210,Teststraße 2,,85353,Augsburg,87456424
Torben Trost,03.3.2026,15.03.2010,54216782,2. Tür rechts,Ringstraße 16,81375,München,4234
Torben Trost,03.3.2026,15.03.2010,54216782,,,,,74532
Name,Ablaufdatum,Startdatum,Geburtsdatum,Passnummer,Adresszeile 1,Adresszeile 2,PLZ,Ort,Pass-ID
Thea Test,03.3.2026,03.3.2024,05.04.1992,12345678,Wertachstraße 29,,86153,Augsburg,35252
Tilo Torvitz,03.3.2026,03.5.2024,13.03.1988,76543210,Teststraße 2,,85353,Augsburg,87456424
Torben Trost,03.3.2026,07.3.2026,15.03.2010,54216782,2. Tür rechts,Ringstraße 16,81375,München,4234
Torben Trost,03.3.2026,03.9.2024,15.03.2010,54216782,,,,,74532
10 changes: 2 additions & 8 deletions administration/src/bp-modules/cards/AddCardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,13 @@ interface CreateCardsFormProps {
onRemove: () => void
}

export const maxCardValidity = { years: 99 }
const ExtensionForm = ({ extension, onUpdate }: ExtensionFormProps) => {
return extension.createForm(() => {
onUpdate()
})
}

const maxCardValidity = { years: 99 }
const hasCardExpirationError = (expirationDate: PlainDate): boolean => {
const today = PlainDate.fromLocalDate(new Date())

return expirationDate.isBefore(today) || expirationDate.isAfter(today.add(maxCardValidity))
}

const CreateCardForm = ({ cardBlueprint, onRemove, onUpdate }: CreateCardsFormProps) => {
const today = PlainDate.fromLocalDate(new Date())
return (
Expand Down Expand Up @@ -66,7 +60,7 @@ const CreateCardForm = ({ cardBlueprint, onRemove, onUpdate }: CreateCardsFormPr
type='date'
required
size='small'
error={cardBlueprint.expirationDate ? hasCardExpirationError(cardBlueprint.expirationDate) : true}
error={!cardBlueprint.isExpirationDateValid()}
value={cardBlueprint.expirationDate ? cardBlueprint.expirationDate.toString() : null}
sx={{ '& input[value=""]:not(:focus)': { color: 'transparent' }, '& fieldset': { borderRadius: 0 } }}
inputProps={{
Expand Down
18 changes: 16 additions & 2 deletions administration/src/cards/CardBlueprint.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { PartialMessage } from '@bufbuild/protobuf'

import { maxCardValidity } from '../bp-modules/cards/AddCardForm'
import { CardExtensions, CardInfo, DynamicActivationCode, QrCode, StaticVerificationCode } from '../generated/card_pb'
import { Region } from '../generated/graphql'
import { CardConfig } from '../project-configs/getProjectConfig'
import PlainDate from '../util/PlainDate'
import { isContentLengthValid } from '../util/qrcode'
import RegionExtension from './extensions/RegionExtension'
import { Extension, ExtensionInstance, JSONExtension } from './extensions/extensions'
import StartDayExtension from './extensions/StartDayExtension'
import { Extension, ExtensionInstance, JSONExtension, findExtension } from './extensions/extensions'
import { PEPPER_LENGTH } from './hashCardInfo'

// Due to limited space on the cards
Expand Down Expand Up @@ -83,9 +85,21 @@ export class CardBlueprint {
)
}

isStartDayBeforeExpirationDay = (expirationDate: PlainDate): boolean => {
const startDayExtension = findExtension(this.extensions, StartDayExtension)
return startDayExtension?.state?.startDay
? PlainDate.fromDaysSinceEpoch(startDayExtension.state.startDay).isBefore(expirationDate)
: true
}

isExpirationDateValid(): boolean {
const today = PlainDate.fromLocalDate(new Date())
return this.expirationDate !== null && this.expirationDate.isAfter(today)
return (
this.expirationDate !== null &&
this.expirationDate.isAfter(today) &&
!this.expirationDate.isAfter(today.add(maxCardValidity)) &&
this.isStartDayBeforeExpirationDay(this.expirationDate)
)
}

setExpirationDate(value: string) {
Expand Down
2 changes: 2 additions & 0 deletions administration/src/cards/createCards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ async function createCards(client: ApolloClient<object>, activationCodes: Codes,
const codeType = code instanceof DynamicActivationCode ? CodeType.Dynamic : CodeType.Static
const cardInfoHash = await hashCardInfo(code.info!, code.pepper)
const expirationDay = code.info!.expirationDay
const startDay = code.info!.extensions?.extensionStartDay?.startDay
const activationSecretBase64 =
code instanceof DynamicActivationCode ? uint8ArrayToBase64(code.activationSecret) : null
return {
Expand All @@ -36,6 +37,7 @@ async function createCards(client: ApolloClient<object>, activationCodes: Codes,
activationSecretBase64: activationSecretBase64,
regionId: region.id,
codeType,
cardStartDay: startDay,
}
})
)
Expand Down
87 changes: 87 additions & 0 deletions administration/src/cards/extensions/StartDayExtension.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { FormGroup } from '@blueprintjs/core'
import { PartialMessage } from '@bufbuild/protobuf'
import { TextField } from '@mui/material'

import { CardExtensions } from '../../generated/card_pb'
import PlainDate from '../../util/PlainDate'
import { Extension } from './extensions'

type StartDayState = { startDay: number }

class StartDayExtension extends Extension<StartDayState, null> {
public readonly name = StartDayExtension.name

setInitialState() {
const today = PlainDate.fromLocalDate(new Date())
this.state = { startDay: today.toDaysSinceEpoch() }
}

createForm(onUpdate: () => void) {
const startDayDate =
this.state?.startDay !== undefined
? PlainDate.fromDaysSinceEpoch(this.state.startDay)
: PlainDate.fromLocalDate(new Date())

return (
<FormGroup label='Startdatum'>
<TextField
fullWidth
type='date'
required
size='small'
error={!this.isValid()}
value={startDayDate.toString()}
sx={{ '& input[value=""]:not(:focus)': { color: 'transparent' }, '& fieldset': { borderRadius: 0 } }}
inputProps={{
style: { fontSize: 14, padding: '6px 10px' },
}}
onChange={e => {
if (e.target.value !== null) {
try {
const date = PlainDate.from(e.target.value)
this.state = { startDay: date.toDaysSinceEpoch() }
onUpdate()
} catch (error) {
console.error("Could not parse date from string '" + e.target.value + "'.", error)
}
}
}}
/>
</FormGroup>
)
}

causesInfiniteLifetime() {
return false
}

setProtobufData(message: PartialMessage<CardExtensions>) {
message.extensionStartDay = {
startDay: this.state?.startDay,
}
}

isValid() {
return this.state !== null
}

/**
* fromString is only used for the CSV import.
* The expected format is dd.MM.yyyy
* @param value The date formatted using dd.MM.yyyy
*/
fromString(value: string) {
try {
const startDay = PlainDate.fromCustomFormat(value, 'dd.MM.yyyy')
this.state = { startDay: startDay.toDaysSinceEpoch() }
} catch (e) {
this.state = null
}
}

toString() {
return this.state ? PlainDate.fromDaysSinceEpoch(this.state.startDay).format('dd.MM.yyyy') : ''
}
}

export default StartDayExtension
2 changes: 2 additions & 0 deletions administration/src/cards/extensions/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import BirthdayExtension from './BirthdayExtension'
import NuernbergPassIdExtension from './NuernbergPassIdExtension'
import NuernbergPassNumberExtension from './NuernbergPassNumberExtension'
import RegionExtension from './RegionExtension'
import StartDayExtension from './StartDayExtension'

export const findExtension = <E extends ExtensionClass>(
array: ExtensionInstance[],
Expand Down Expand Up @@ -39,5 +40,6 @@ export type ExtensionClass =
| typeof NuernbergPassNumberExtension
| typeof NuernbergPassIdExtension
| typeof RegionExtension
| typeof StartDayExtension
| (typeof AddressExtensions)[number]
export type ExtensionInstance = InstanceType<ExtensionClass>
54 changes: 54 additions & 0 deletions administration/src/cards/hashCardInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CardInfo,
NuernbergPassNumberExtension,
RegionExtension,
StartDayExtension,
} from '../generated/card_pb'
import { base64ToUint8Array, uint8ArrayToBase64 } from '../util/base64'
import hashCardInfo, { messageToJsonObject } from './hashCardInfo'
Expand Down Expand Up @@ -99,6 +100,36 @@ describe('messageToJsonObject', () => {
},
})
})

it('should map a cardInfo for a Nuernberg Pass with startDay correctly', () => {
const cardInfo = new CardInfo({
fullName: 'Max Mustermann',
expirationDay: 365 * 40, // Equals 14.600
extensions: new CardExtensions({
extensionBirthday: new BirthdayExtension({
birthday: -365 * 10,
}),
extensionNuernbergPassNumber: new NuernbergPassNumberExtension({
passNumber: 99999999,
}),
extensionRegion: new RegionExtension({
regionId: 93,
}),
extensionStartDay: new StartDayExtension({ startDay: 365 * 2 }),
}),
})

expect(messageToJsonObject(cardInfo)).toEqual({
'1': 'Max Mustermann',
'2': '14600',
'3': {
'1': { '1': '93' }, // extensionRegion
'2': { '1': '-3650' }, // extensionBirthday
'3': { '1': '99999999' }, // extensionNuernbergPassNumber
'5': { '1': '730' }, // extensionStartDay
},
})
})
})

describe('hashCardInfo', () => {
Expand Down Expand Up @@ -162,4 +193,27 @@ describe('hashCardInfo', () => {

expect(uint8ArrayToBase64(hash)).toBe('zogEJOhnSSp//8qhym/DdorQYgL/763Kfq4slWduxMg=')
})

it('should be stable for a Nuernberg Pass with startDay', async () => {
const cardInfo = new CardInfo({
fullName: 'Max Mustermann',
expirationDay: 365 * 40, // Equals 14.600
extensions: new CardExtensions({
extensionRegion: new RegionExtension({
regionId: 93,
}),
extensionBirthday: new BirthdayExtension({
birthday: -365 * 10,
}),
extensionNuernbergPassNumber: new NuernbergPassNumberExtension({
passNumber: 99999999,
}),
extensionStartDay: new StartDayExtension({ startDay: 365 * 2 }),
}),
})
const pepper = base64ToUint8Array('MvMjEqa0ulFDAgACElMjWA==')
const hash = await hashCardInfo(cardInfo, pepper)

expect(uint8ArrayToBase64(hash)).toBe('1ChHiAvWygwu+bH2yOZOk1zdmwTDZ4mkvu079cyuLjE=')
})
})
3 changes: 3 additions & 0 deletions administration/src/project-configs/nuernberg/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import BirthdayExtension from '../../cards/extensions/BirthdayExtension'
import NuernbergPassIdExtension from '../../cards/extensions/NuernbergPassIdExtension'
import NuernbergPassNumberExtension from '../../cards/extensions/NuernbergPassNumberExtension'
import RegionExtension from '../../cards/extensions/RegionExtension'
import StartDayExtension from '../../cards/extensions/StartDayExtension'
import { ProjectConfig } from '../getProjectConfig'
import ActivityLogEntry from './ActivityLogEntry'
import { DataPrivacyBaseText, dataPrivacyBaseHeadline } from './dataPrivacyBase'
Expand All @@ -17,6 +18,7 @@ const config: ProjectConfig = {
nameColumnName: 'Name',
expiryColumnName: 'Ablaufdatum',
extensionColumnNames: [
'Startdatum',
'Geburtsdatum',
'Passnummer',
'Pass-ID',
Expand All @@ -28,6 +30,7 @@ const config: ProjectConfig = {
],
defaultValidity: { years: 1 },
extensions: [
StartDayExtension,
BirthdayExtension,
NuernbergPassNumberExtension,
NuernbergPassIdExtension,
Expand Down
3 changes: 2 additions & 1 deletion administration/src/project-configs/nuernberg/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ const renderPdfDetails = ({ info, cardBlueprint }: InfoParams) => {
const passId = findExtension(cardBlueprint.extensions, NuernbergPassIdExtension)?.state?.nuernbergPassId
const expirationDate = PlainDate.fromDaysSinceEpoch(expirationDay)
const birthdayDate = PlainDate.fromDaysSinceEpoch(info.extensions?.extensionBirthday?.birthday ?? 0)
const startDate = PlainDate.fromDaysSinceEpoch(info.extensions?.extensionStartDay?.startDay ?? 0)
return `${info.fullName}
Pass-ID: ${passId ?? ''}
Geburtsdatum: ${birthdayDate.format('dd.MM.yyyy')}
Gültig bis: ${expirationDate.format('dd.MM.yyyy')}`
Gültig: ${startDate.format('dd.MM.yyyy')} bis ${expirationDate.format('dd.MM.yyyy')}`
}

const createAddressFormFields = (form: PDFForm, pageIdx: number, { info, cardBlueprint }: InfoParams) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ object MigrationsRegistry {
V0003_AddFirstActivationDate(),
V0004_AddNotificationSettings(),
V0005_AddRegionApplicationActivation(),
V0006_AddStoreCreatedDate()
V0006_AddStoreCreatedDate(),
V0007_AddStartDay()
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package app.ehrenamtskarte.backend.migration.migrations

import app.ehrenamtskarte.backend.migration.Migration
import app.ehrenamtskarte.backend.migration.Statement

/**
* Add startDay column to cards. startDay can be null.
*/
@Suppress("ClassName")
internal class V0007_AddStartDay() : Migration() {
override val migrate: Statement = {
exec(
"""
ALTER TABLE cards ADD "startDay" BIGINT NULL;
""".trimIndent()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,18 @@ class ValidityPeriodUtil {
fun daysSinceEpochToDate(days: Long): LocalDate {
return LocalDate.of(1970, Month.JANUARY, 1).plusDays(days)
}

fun isOnOrAfterToday(maxInclusiveDay: LocalDate, timezone: ZoneId): Boolean {
return isOnOrAfterToday(maxInclusiveDay, Clock.system(timezone))
}

fun isOnOrAfterToday(maxInclusiveDay: LocalDate, clock: Clock): Boolean {
// Cards issues for day == 0 are never valid!
if (maxInclusiveDay.isEqual(LocalDate.of(1970, Month.JANUARY, 1))) {
return false
}
// not before includes the current day
return !LocalDate.now(clock).isBefore(maxInclusiveDay)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object Cards : IntIdTable() {

// Days since 1970-01-01. For more information refer to the card.proto,
// Using long because unsigned ints are not available, but we want to be able to represent them.
// If this field is null, the card is valid forever.
val expirationDay = long("expirationDay").nullable()
val issueDate = timestamp("issueDate").defaultExpression(CurrentTimestamp())
val revoked = bool("revoked")
Expand All @@ -36,6 +37,11 @@ object Cards : IntIdTable() {
val codeType = enumeration("codeType", CodeType::class)
val firstActivationDate = timestamp("firstActivationDate").nullable()

// startDay describes the first day on which the card is valid.
// If this field is null, the card is valid until `expirationDay` without explicitly stating when the validity period started.
// Days since 1970-01-01. For more information refer to the card.proto,
val startDay = long("startDay").nullable()

init {
check("CodeTypeConstraint") {
(
Expand All @@ -61,4 +67,5 @@ class CardEntity(id: EntityID<Int>) : IntEntity(id) {
var issuerId by Cards.issuerId
var codeType by Cards.codeType
var firstActivationDate by Cards.firstActivationDate
var startDay by Cards.startDay
}
Loading

0 comments on commit 59dbdf3

Please sign in to comment.