Files
alpenwerk-hr/supabase/migrations/20260727120200_om_cutover.sql
Maximilian Stubhan c2366e3408 Make the cut-over script safe to paste, and record the Azure design
The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor
the transaction boundaries are not ours to assume, and a mapping table that
vanished between the two inserts would leave positions without assignments
and be miserable to diagnose. It is now dropped explicitly once both inserts
have run.

docs/azure-migration.md is the design for the Azure move, for review before
any code changes.

Its main finding corrects what I said when I laid out the options: I claimed
that dropping Supabase would push the security boundary into application
code. It does not. auth.uid() appears 70 times, but only one of them matters
— inside is_hr_user(), which all 58 policies call. Swapping the source of
the user id there leaves every policy valid, so the database stays the
boundary.

The risk moves elsewhere, and the design says so plainly: the user id
arrives via set_config(..., true), which is transaction-local. Outside a
transaction it sticks to the pooled connection, and the next request on that
connection runs as the previous user. So the plan makes that structurally
impossible — a single access function that owns the transaction, a lint rule
against importing the pool anywhere else, a database role without BYPASSRLS
so a missing context returns nothing rather than everything, and a test that
sends two requests over one pooled connection to prove the second cannot see
the first.
2026-07-27 14:39:57 +02:00

528 lines
24 KiB
PL/PgSQL

-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
--
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
--
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
-- "division_id = X and team_id is null and org_level = 1" — eine
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
-- willkürlich eine von beiden.
--
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
-- das Organigramm gefüllt.
--
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
-- sind — die Berichtslinie bleibt durchgängig.
begin;
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
-- die Personen, die daran hingen, ihre Einheit behalten.
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
from divisions where name = 'Geschäftsführung';
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select d.id, d.org_number, d.name,
(select id from org_units where unit_type = 'Gesellschaft'),
'Bereich', '2000-01-01'
from divisions d
where d.name <> 'Geschäftsführung';
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select dep.id, dep.org_number, dep.name,
coalesce((select u.id from org_units u where u.id = dep.division_id),
(select id from org_units where unit_type = 'Gesellschaft')),
'Abteilung', '2000-01-01'
from departments dep;
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
from teams t;
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
-- Titel der neuen Abteilungsleitungen.
insert into jobs (code, title)
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
from (
select distinct job_title as title from employees where job_title is not null
union
select distinct title from positions where title is not null
union
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
) t
on conflict (title) do nothing;
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
-- doch verschieden.
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
create temporary table om_pos_map (
employee_id uuid primary key,
position_id uuid not null default gen_random_uuid(),
seq bigint
);
insert into om_pos_map (employee_id, seq)
select id, row_number() over (order by org_level, personnel_number) from employees;
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
select
m.position_id,
'6' || lpad(m.seq::text, 7, '0'),
case
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
end,
(select j.id from jobs j where j.title = e.job_title),
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
e.entry_date
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
-- wieder frei, die Historie bleibt.
--
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
-- position_assignments verlangt aber valid_to > valid_from. Ein
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
select m.position_id, e.id, e.entry_date,
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
u.id,
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
true,
current_date
from org_units u
where u.unit_type = 'Abteilung'
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
-- ohnehin nicht zu.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select p.position_number, p.team_id,
(select id from jobs j where j.title = p.title),
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
p.valid_from
from positions p
where p.status = 'open'
and exists (select 1 from org_units u where u.id = p.team_id);
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
-- als ein halb überführter Bestand ohne Rückweg.
do $$
declare v_fehlend int;
begin
select count(*) into v_fehlend
from employees e
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
if v_fehlend > 0 then
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
end if;
select count(*) into v_fehlend from om_positions where job_id is null;
if v_fehlend > 0 then
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
end if;
select count(*) into v_fehlend
from org_units u
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
if v_fehlend > 0 then
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
end if;
end $$;
drop table om_pos_map;
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
-- transient; halb übersetzt wären sie schlimmer als verworfen.
delete from pending_org_changes where status = 'pending';
drop trigger if exists trg_track_employee_assignment on employees;
drop function if exists fn_track_employee_assignment();
drop table if exists employee_assignments;
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
-- om_reporting_lines() tritt an ihre Stelle.
drop view if exists employees_directory;
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
-- Umstellung der Oberfläche.
drop function if exists resolve_manager_for(uuid, boolean, uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists create_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(uuid);
drop function if exists hire_employee(jsonb);
drop function if exists terminate_employee(jsonb);
drop function if exists transfer_employee(jsonb);
drop function if exists rehire_employee(jsonb);
drop function if exists record_karenz_return(jsonb);
drop function if exists apply_due_pending_changes();
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
drop table if exists positions;
drop table if exists teams;
drop table if exists departments;
drop table if exists divisions;
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id and pa.valid_to is null;
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
)
values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false)
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung',
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$$;
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set
status = case when v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
end;
$$;
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_target_position uuid := (payload->>'target_position_id')::uuid;
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
v_name text;
v_besetzt uuid;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_position and pa.valid_to is null;
if v_besetzt is not null and v_besetzt <> v_employee_id then
raise exception 'Die Zielplanstelle ist bereits besetzt.';
end if;
if v_effective <= current_date then
update position_assignments set valid_to = v_effective
where employee_id = v_employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_target_position, v_employee_id, v_effective);
update employees set job_title =
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective,
jsonb_build_object('target_position_id', v_target_position));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
(select position_number from om_positions where id = v_target_position));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
end;
$$;
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
end;
$$;
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::relationship_type,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$$;
commit;